feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+149 -23
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -7,7 +7,12 @@ import {
} from "@/components/InlineTokenHighlight";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo, SessionMention } from "@/lib/types";
import type {
CliAppInfo,
McpPresetInfo,
SessionHandle,
SessionMention,
} from "@/lib/types";
import { cn } from "@/lib/utils";
type CliAppMentionSegment =
@@ -17,8 +22,56 @@ type CliAppMentionSegment =
export type CapabilityMentionSegment =
| CliAppMentionSegment
| { kind: "mcp"; text: string; preset: McpPresetInfo }
| { kind: "handle"; text: string; handle: SessionHandle };
export type SessionReferenceSegment =
| { kind: "text"; text: string }
| { kind: "session"; text: string; mention: SessionMention };
export interface TokenSelection<T> {
mention: T;
start: number;
end: number;
}
export type SessionHandleSelection = TokenSelection<SessionHandle>;
export type SessionMentionSelection = TokenSelection<SessionMention>;
const SESSION_HANDLE_COLOR_COUNT = 8;
export function sessionHandleColor(colorSlot: number): string {
const slot = Number.isFinite(colorSlot)
? Math.abs(Math.trunc(colorSlot)) % SESSION_HANDLE_COLOR_COUNT
: 0;
return `var(--session-handle-${slot})`;
}
export function SessionHandleHighlight({
handle,
children,
className,
testId,
}: {
handle: Pick<SessionHandle, "color_slot" | "name">;
children: ReactNode;
className?: string;
testId?: string;
}) {
return (
<span
className="inline border-b-2"
style={{ borderBottomColor: sessionHandleColor(handle.color_slot) }}
>
<InlineTokenHighlight
testId={testId}
className={cn("text-foreground", className)}
>
{children}
</InlineTokenHighlight>
</span>
);
}
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
@@ -30,6 +83,7 @@ export function cliAppInitials(app: CliAppInfo): string {
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
const value = preset.display_name || preset.name;
return (
@@ -41,13 +95,15 @@ export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_
.join("") || preset.name.slice(0, 2).toUpperCase()
);
}
export function splitCapabilityMentionSegments(
value: string,
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[] = [],
sessionMentions: SessionMention[] = [],
sessionHandles: SessionHandle[] = [],
handleSelections?: SessionHandleSelection[],
): CapabilityMentionSegment[] {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0 && sessionMentions.length === 0)) {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0 && sessionHandles.length === 0)) {
return value ? [{ kind: "text", text: value }] : [];
}
const cliAppsByName = new Map(
@@ -60,10 +116,13 @@ export function splitCapabilityMentionSegments(
.filter((preset) => preset.installed && preset.configured)
.map((preset) => [preset.name.toLowerCase(), preset]),
);
const sessionsByName = new Map(
sessionMentions.map((mention) => [mention.name.toLowerCase(), mention]),
const handlesByName = new Map(
sessionHandles.map((handle) => [handle.name.toLowerCase(), handle]),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0 && sessionsByName.size === 0) {
const selectedSessionNames = new Set(
(handleSelections ?? []).map((selection) => selection.mention.name.toLowerCase()),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0 && handlesByName.size === 0) {
return [{ kind: "text", text: value }];
}
@@ -75,13 +134,15 @@ export function splitCapabilityMentionSegments(
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const key = name.toLowerCase();
const app = cliAppsByName.get(key);
const preset = app ? null : mcpPresetsByName.get(key);
const session = app || preset ? null : sessionsByName.get(key);
if (!app && !preset && !session) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
const handle = handleSelections
? selectedSessionNames.has(key) ? handlesByName.get(key) : undefined
: handlesByName.get(key);
const app = handle ? null : cliAppsByName.get(key);
const preset = handle || app ? null : mcpPresetsByName.get(key);
if (!app && !preset && !handle) continue;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
@@ -89,18 +150,51 @@ export function splitCapabilityMentionSegments(
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
} else if (preset) {
segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset });
} else if (session) {
segments.push({
kind: "session",
text: value.slice(mentionStart, mentionEnd),
mention: session,
});
} else if (handle) {
segments.push({ kind: "handle", text: value.slice(mentionStart, mentionEnd), handle });
}
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
if (cursor < value.length) segments.push({ kind: "text", text: value.slice(cursor) });
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function splitSessionReferenceSegments(
value: string,
sessionMentions: SessionMention[] = [],
sessionSelections?: SessionMentionSelection[],
allowLegacyAt = false,
): SessionReferenceSegment[] {
if (!value || sessionMentions.length === 0) return value ? [{ kind: "text", text: value }] : [];
const sessionsByName = new Map(
sessionMentions.map((mention) => [mention.name.toLowerCase(), mention]),
);
const selectedSessionByStart = new Map(
(sessionSelections ?? []).map((selection) => [selection.start, selection]),
);
const segments: SessionReferenceSegment[] = [];
const referenceRe = allowLegacyAt
? /(^|[\s([{])([#@])([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu
: /(^|[\s([{])(#)([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = referenceRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[3] ?? "";
const start = match.index + prefix.length;
const end = start + name.length + 1;
const selected = selectedSessionByStart.get(start);
const mention = sessionSelections
? selected?.end === end && selected.mention.name.toLowerCase() === name.toLowerCase()
? selected.mention
: undefined
: sessionsByName.get(name.toLowerCase());
if (!mention) continue;
if (start > cursor) segments.push({ kind: "text", text: value.slice(cursor, start) });
segments.push({ kind: "session", text: value.slice(start, end), mention });
cursor = end;
}
if (cursor < value.length) segments.push({ kind: "text", text: value.slice(cursor) });
return segments.length ? segments : [{ kind: "text", text: value }];
}
@@ -133,10 +227,42 @@ export function CapabilityMentionToken({
/>
);
}
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
return <SessionHandleToken handle={segment.handle} label={segment.text} variant={variant} />;
}
export function SessionMentionToken({
export function SessionHandleToken({
handle,
label,
variant,
}: {
handle: SessionHandle;
label: string;
variant: "composer" | "message";
}) {
const testIdPrefix = variant === "composer" ? "composer" : "message";
const color = sessionHandleColor(handle.color_slot);
const token = (
<SessionHandleHighlight
handle={handle}
testId={`${testIdPrefix}-handle-mention-${handle.name}`}
className={variant === "composer" ? "font-normal" : undefined}
>
{label}
</SessionHandleHighlight>
);
if (variant === "composer" || !handle.session_key) return token;
return (
<a
href={`#/chat/${encodeURIComponent(handle.session_key)}`}
className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
style={{ textDecorationColor: color }}
>
{token}
</a>
);
}
export function SessionReferenceToken({
mention,
label,
variant,
@@ -148,7 +274,7 @@ export function SessionMentionToken({
const testIdPrefix = variant === "composer" ? "composer" : "message";
const token = (
<InlineTokenHighlight
testId={`${testIdPrefix}-session-mention-${mention.name}`}
testId={`${testIdPrefix}-session-reference-${mention.name}`}
title={`Session: ${mention.title || mention.name}`}
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className={variant === "composer" ? "font-normal" : undefined}