refactor(session): simplify cross-session flow

This commit is contained in:
Xubin Ren
2026-08-04 12:14:51 +08:00
parent 62d34b5eb7
commit d8aeb0eb2c
12 changed files with 282 additions and 574 deletions
+35 -20
View File
@@ -123,27 +123,10 @@ export function CliAppMentionText({
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
<CapabilityMentionToken
key={`${segment.kind}-${index}`}
segment={segment}
variant="message"
/>
);
@@ -152,6 +135,38 @@ export function CliAppMentionText({
);
}
export function CapabilityMentionToken({
segment,
variant,
isHero = false,
}: {
segment: Exclude<CapabilityMentionSegment, { kind: "text" }>;
variant: "composer" | "message";
isHero?: boolean;
}) {
if (segment.kind === "cli") {
return (
<CliAppMentionToken
app={segment.app}
label={segment.text}
variant={variant}
isHero={isHero}
/>
);
}
if (segment.kind === "mcp") {
return (
<McpPresetMentionToken
preset={segment.preset}
label={segment.text}
variant={variant}
isHero={isHero}
/>
);
}
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
}
export function SessionMentionToken({
mention,
label,
+4 -23
View File
@@ -2,9 +2,7 @@ import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import {
CliAppMentionToken,
McpPresetMentionToken,
SessionMentionToken,
CapabilityMentionToken,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
@@ -98,27 +96,10 @@ export function UserMessageText({
{segment.text}
</InlineTokenHighlight>
);
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
<CapabilityMentionToken
key={`${segment.kind}-${index}`}
segment={segment}
variant="message"
/>
);
+70 -97
View File
@@ -11,9 +11,7 @@ import {
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
McpPresetMentionToken,
SessionMentionToken,
CapabilityMentionToken,
cliAppInitials,
mcpPresetInitials,
splitCapabilityMentionSegments,
@@ -301,10 +299,18 @@ interface CliAppMentionQuery {
end: number;
}
type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo }
| { kind: "session"; name: string; mention: SessionMention };
type MentionCandidate = {
name: string;
displayName: string;
} & (
| { kind: "session"; mention: SessionMention }
| {
kind: "cli" | "mcp";
brandColor: string | null;
logoUrl: string | null;
initials: string;
}
);
function sessionMentionBase(session: ChatSummary): string {
const label = session.title?.trim() || session.preview.trim() || "session";
@@ -1248,12 +1254,24 @@ export function ThreadComposer({
),
[cliApps, mcpPresets, sessions],
);
const mentionSegments = useMemo(
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
[cliApps, mcpPresets, selectedSessionMentions, value],
);
const activeSessionMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "session" || seen.has(segment.mention.session_key)) return [];
seen.add(segment.mention.session_key);
return [segment.mention];
}).slice(0, SESSION_MENTIONS_LIMIT);
}, [mentionSegments]);
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
if (!cliAppMention) return [];
const sessionCandidates: MentionCandidate[] = availableSessionMentions
.filter((mention) => (
selectedSessionMentions.length < SESSION_MENTIONS_LIMIT
|| selectedSessionMentions.some(
activeSessionMentions.length < SESSION_MENTIONS_LIMIT
|| activeSessionMentions.some(
(selected) => selected.session_key === mention.session_key,
)
))
@@ -1264,6 +1282,7 @@ export function ThreadComposer({
.map((mention) => ({
kind: "session",
name: mention.name,
displayName: mention.title || mention.name,
mention,
}));
const cliCandidates: MentionCandidate[] = cliApps
@@ -1278,7 +1297,14 @@ export function ThreadComposer({
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((app) => ({ kind: "cli", name: app.name, app }));
.map((app) => ({
kind: "cli",
name: app.name,
displayName: app.display_name,
brandColor: app.brand_color ?? null,
logoUrl: app.logo_url ?? null,
initials: cliAppInitials(app),
}));
const mcpCandidates: MentionCandidate[] = mcpPresets
.filter((preset) => preset.installed && preset.configured)
.filter((preset) => {
@@ -1291,29 +1317,26 @@ export function ThreadComposer({
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
const groups = [sessionCandidates, cliCandidates, mcpCandidates];
const limits = groups.map((group, index) => Math.min(group.length, [4, 2, 2][index]));
let remaining = 8 - limits.reduce((total, limit) => total + limit, 0);
for (let index = 0; index < groups.length && remaining > 0; index += 1) {
const extra = Math.min(groups[index].length - limits[index], remaining);
limits[index] += extra;
remaining -= extra;
}
return groups.flatMap((group, index) => group.slice(0, limits[index]));
}, [availableSessionMentions, cliAppMention, cliApps, mcpPresets, selectedSessionMentions]);
.map((preset) => ({
kind: "mcp",
name: preset.name,
displayName: preset.display_name,
brandColor: preset.brand_color ?? null,
logoUrl: preset.logo_url ?? null,
initials: mcpPresetInitials(preset),
}));
return [
...sessionCandidates.slice(0, 4),
...cliCandidates.slice(0, 2),
...mcpCandidates.slice(0, 2),
...sessionCandidates.slice(4),
...cliCandidates.slice(2),
...mcpCandidates.slice(2),
].slice(0, 8);
}, [activeSessionMentions, availableSessionMentions, cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCapabilityMentionSegments(
value,
cliApps,
mcpPresets,
selectedSessionMentions,
),
[cliApps, mcpPresets, selectedSessionMentions, value],
);
const hasMentionDecorations = mentionSegments.some(
(segment) => segment.kind !== "text",
);
@@ -1333,28 +1356,6 @@ export function ThreadComposer({
return [segment.preset];
});
}, [mentionSegments]);
const activeSessionMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "session" || seen.has(segment.mention.session_key)) return [];
seen.add(segment.mention.session_key);
return [segment.mention];
}).slice(0, SESSION_MENTIONS_LIMIT);
}, [mentionSegments]);
useEffect(() => {
setSelectedSessionMentions((current) => {
if (
current.length === activeSessionMentions.length
&& current.every((mention, index) => {
const active = activeSessionMentions[index];
return mention.name === active.name
&& mention.session_key === active.session_key
&& mention.title === active.title;
})
) return current;
return activeSessionMentions;
});
}, [activeSessionMentions]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -1596,8 +1597,11 @@ export function ThreadComposer({
if (!cliAppMention) return;
if (candidate.kind === "session") {
const name = candidate.name.toLowerCase();
setSelectedSessionMentions((current) => [
...current.filter((mention) => mention.name.toLowerCase() !== name),
setSelectedSessionMentions([
...activeSessionMentions.filter((mention) => (
mention.name.toLowerCase() !== name
&& mention.session_key !== candidate.mention.session_key
)),
candidate.mention,
]);
}
@@ -1618,7 +1622,7 @@ export function ThreadComposer({
el.setSelectionRange(nextCursor, nextCursor);
});
},
[cliAppMention, resizeTextarea, value],
[activeSessionMentions, cliAppMention, resizeTextarea, value],
);
const clearComposerText = useCallback((restoreFocus = true) => {
@@ -2595,30 +2599,12 @@ function ComposerCliMentionOverlay({
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
<CapabilityMentionToken
key={`${segment.kind}-${index}`}
segment={segment}
variant="composer"
isHero={isHero}
/>
);
})}
@@ -2709,11 +2695,6 @@ function CliAppMentionPalette({
{group.items.map(({ candidate, index }) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.kind === "mcp"
? candidate.preset.display_name
: candidate.mention.title || candidate.name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: candidate.kind === "mcp"
@@ -2731,7 +2712,7 @@ function CliAppMentionPalette({
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
aria-label={`${candidate.displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
@@ -2747,7 +2728,7 @@ function CliAppMentionPalette({
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-[15px] font-medium tracking-normal text-foreground">
{displayName}
{candidate.displayName}
</span>
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
@{name}
@@ -2782,16 +2763,10 @@ function MentionCandidateLogo({
candidate: MentionCandidate;
selected: boolean;
}) {
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.kind === "mcp"
? candidate.preset.brand_color
: null) || INLINE_TOKEN_HIGHLIGHT_COLOR;
const rawLogoUrl = candidate.kind === "cli"
? candidate.app.logo_url
: candidate.kind === "mcp"
? candidate.preset.logo_url
: null;
const color = candidate.kind === "session"
? INLINE_TOKEN_HIGHLIGHT_COLOR
: candidate.brandColor || INLINE_TOKEN_HIGHLIGHT_COLOR;
const rawLogoUrl = candidate.kind === "session" ? null : candidate.logoUrl;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
@@ -2827,9 +2802,7 @@ function MentionCandidateLogo({
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{candidate.kind === "cli"
? cliAppInitials(candidate.app)
: mcpPresetInitials(candidate.preset)}
{candidate.initials}
</span>
);
}
+48 -119
View File
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
vi.mock("@/lib/imageEncode", () => ({
encodeImage: vi.fn(async (file: File) => ({
@@ -125,6 +125,18 @@ const MCP_PRESETS: McpPresetInfo[] = [
},
];
function session(chatId: string, title: string, preview = ""): ChatSummary {
return {
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: null,
updatedAt: null,
title,
preview,
};
}
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices;
@@ -1536,25 +1548,24 @@ describe("ThreadComposer", () => {
});
});
it("reuses the mention palette for persisted sessions", () => {
it("attaches persisted sessions only through the shared mention palette", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[{
key: "websocket:pricing",
channel: "websocket",
chatId: "pricing",
createdAt: null,
updatedAt: null,
title: "收费设计",
preview: "讨论云存储",
}]}
sessions={[session("pricing", "收费设计", "讨论云存储")]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "普通文字 @收费设计", selectionStart: 10 },
});
expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenLastCalledWith("普通文字 @收费设计", undefined, undefined);
fireEvent.change(input, {
target: { value: "参考 @收费", selectionStart: 6 },
});
@@ -1579,49 +1590,16 @@ describe("ThreadComposer", () => {
});
});
it("attaches a session only after an explicit palette selection", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[{
key: "websocket:pricing",
channel: "websocket",
chatId: "pricing",
createdAt: null,
updatedAt: null,
title: "收费设计",
preview: "讨论云存储",
}]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "普通文字 @收费设计", selectionStart: 10 },
});
expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("普通文字 @收费设计", undefined, undefined);
});
it("shows stable aliases for sessions with the same title", () => {
it("disambiguates duplicate and capability-colliding session names", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
sessions={["a", "b"].map((chatId) => ({
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: null,
updatedAt: null,
title: "Plan",
preview: "",
}))}
cliApps={CLI_APPS}
sessions={[
...["a", "b"].map((chatId) => session(chatId, "Plan")),
session("blender-chat", "Blender", "3D notes"),
]}
/>,
);
@@ -1633,52 +1611,50 @@ describe("ThreadComposer", () => {
expect.stringContaining("@Plan"),
expect.stringContaining("@Plan-chat"),
]);
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "CLI apps" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Blender @Blender-chat Reference/i }))
.toBeInTheDocument();
expect(screen.getByRole("option", { name: /Blender @blender Use/i }))
.toBeInTheDocument();
});
it("keeps the composer and wire payload on the same eight-session limit", () => {
it("releases the eight-session limit when a mention is removed", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={Array.from({ length: 9 }, (_, index) => ({
key: `websocket:topic-${index}`,
channel: "websocket",
chatId: `topic-${index}`,
createdAt: null,
updatedAt: null,
title: `Topic${index}`,
preview: "",
}))}
sessions={Array.from(
{ length: 9 },
(_, index) => session(`topic-${index}`, `Topic${index}`),
)}
/>,
);
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
for (let index = 0; index < 9; index += 1) {
for (let index = 0; index < 8; index += 1) {
const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
fireEvent.change(input, { target: { value, selectionStart: value.length } });
fireEvent.keyDown(input, { key: "Tab" });
}
const replacement = `${input.value.replace("@Topic0 ", "")} @Topic8`;
fireEvent.change(input, {
target: { value: replacement, selectionStart: replacement.length },
});
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
const options = onSend.mock.calls[0]?.[2];
expect(options.sessionMentions).toHaveLength(8);
expect(options.sessionMentions.map((mention: { session_key: string }) => (
mention.session_key
))).not.toContain("websocket:topic-8");
))).toEqual(expect.arrayContaining(["websocket:topic-8"]));
});
it("keeps a selected session stable across refreshes and queued guidance", () => {
const onSend = vi.fn();
const target = {
key: "websocket:z-target",
channel: "websocket",
chatId: "z-target",
createdAt: null,
updatedAt: null,
title: "Plan",
preview: "Original plan",
};
const target = session("z-target", "Plan", "Original plan");
const { rerender } = render(
<ThreadComposer
onSend={onSend}
@@ -1701,12 +1677,7 @@ describe("ThreadComposer", () => {
placeholder="Type your message..."
sessions={[
{ ...target, title: "Renamed plan" },
{
...target,
key: "websocket:a-new",
chatId: "a-new",
title: "Plan",
},
session("a-new", "Plan", target.preview),
]}
/>,
);
@@ -1725,48 +1696,6 @@ describe("ThreadComposer", () => {
});
});
it("disambiguates a session mention that shares a capability name", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
sessions={[
{
key: "websocket:blender-chat",
channel: "websocket",
chatId: "blender-chat",
createdAt: null,
updatedAt: null,
title: "Blender",
preview: "3D notes",
},
...Array.from({ length: 8 }, (_, index) => ({
key: `websocket:chat-${index}`,
channel: "websocket",
chatId: `chat-${index}`,
createdAt: null,
updatedAt: null,
title: `Chat ${index}`,
preview: "",
})),
]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "CLI apps" })).toBeInTheDocument();
expect(screen.getByRole("option", {
name: /Blender @Blender-chat Reference/i,
})).toBeInTheDocument();
expect(screen.getByRole("option", {
name: /Blender @blender Use/i,
})).toBeInTheDocument();
});
it("opens skills only from a $ reference and prioritizes the skill name", () => {
const skillName = "arxiv-intelligence-filter";
render(