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
+35 -1
View File
@@ -1049,7 +1049,7 @@ describe("webui API helpers", () => {
);
});
it("maps generated session titles from the sessions list", async () => {
it("maps title-free handle handles", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -1061,6 +1061,11 @@ describe("webui API helpers", () => {
title: "优化 WebUI 标题",
model_preset: "fast",
run_started_at: 1_700_000_000,
handle: {
id: "handle_1234567890abcdef1234567890abcdef",
name: "webui-review",
color_slot: 5,
},
},
],
}),
@@ -1073,10 +1078,39 @@ describe("webui API helpers", () => {
preview: "",
modelPreset: "fast",
runStartedAt: 1_700_000_000,
handle: {
id: "handle_1234567890abcdef1234567890abcdef",
name: "webui-review",
color_slot: 5,
session_key: "websocket:chat-1",
},
},
]);
});
it("rejects malformed session-list handle DTOs instead of trusting enriched fields", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
sessions: [
{
key: "websocket:chat-1",
created_at: null,
updated_at: null,
handle: {
id: "handle_1234567890abcdef1234567890abcdef",
name: "valid-handle",
color_slot: 8,
session_key: "websocket:attacker-controlled",
},
},
],
}),
} as Response);
await expect(listSessions("tok")).resolves.toMatchObject([{ handle: null }]);
});
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+2 -2
View File
@@ -519,7 +519,7 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const firstMessage = "keep this first turn visible";
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: firstMessage },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -3375,7 +3375,7 @@ describe("App layout", () => {
.toEqual(["Alpha", "New topic"]);
const activeComposer = screen.getByTestId("active-pane-composer");
const paneInput = within(activeComposer).getByRole("textbox", {
const paneInput = within(activeComposer).getByRole("combobox", {
name: "Message New topic",
});
expect(paneInput).toHaveClass("min-h-[50px]");
+102 -2
View File
@@ -66,6 +66,104 @@ describe("ChatList", () => {
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
});
it("keeps each handle handle visible beside its conversation title", () => {
render(
<ChatList
sessions={[session({
chatId: "review",
title: "Review the patch",
handle: {
id: "handle_1234",
name: "mira",
color_slot: 3,
session_key: "websocket:review",
},
})]}
activeKey="websocket:review"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const conversation = screen.getByRole("button", {
name: "@mira Review the patch",
});
expect(conversation).toHaveTextContent("Review the patch");
expect(conversation).toHaveTextContent("@mira");
expect(conversation.querySelector("[data-sidebar-handle-handle]"))
.toHaveClass("max-w-20", "shrink-0");
const handle = conversation.querySelector("[data-sidebar-handle-handle]");
expect(handle?.querySelector("[aria-hidden]")).toBeNull();
const decoration = handle?.querySelector("span[style*='border-bottom-color']");
expect(decoration?.getAttribute("style"))
.toContain("var(--session-handle-3)");
expect(decoration?.querySelector("[data-testid], .text-foreground"))
.toHaveClass("text-foreground");
const selectionTrack = conversation.querySelector("[data-sidebar-selection-track]");
expect(selectionTrack).toHaveAttribute("data-active", "true");
expect(selectionTrack?.getAttribute("style")).toContain("var(--session-handle-3)");
});
it("keeps aligned handle handles when conversations become grouped panes", () => {
const mira = {
id: "handle_1234",
name: "mira",
color_slot: 3,
session_key: "websocket:root",
};
const nora = {
id: "handle_5678",
name: "nora",
color_slot: 5,
session_key: "websocket:child",
};
render(
<ChatList
sessions={[session({
key: "tab:group",
chatId: "workbench-tab:group",
title: "Grouped work",
})]}
activeKey="websocket:root"
paneGroups={{
"tab:group": {
tabKey: "tab:group",
title: "Grouped work",
activePaneKey: "websocket:root",
visible: true,
panes: [
{ key: "websocket:root", chatId: "root", title: "Short", handle: mira },
{
key: "websocket:child",
chatId: "child",
title: "A much longer conversation title",
handle: nora,
},
],
},
}}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const root = screen.getByRole("button", { name: "@mira Short" });
const child = screen.getByRole("button", {
name: "@nora A much longer conversation title",
});
expect(root).toHaveTextContent("@mira");
expect(child).toHaveTextContent("@nora");
for (const handle of document.querySelectorAll("[data-sidebar-handle-handle]")) {
expect(handle).toHaveClass("max-w-20", "shrink-0");
}
});
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
<ChatList
@@ -982,8 +1080,10 @@ describe("ChatList", () => {
const activeButton = screen.getByRole("button", { name: "Active topic" });
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
const activeTrack = activeButton.querySelector("[data-sidebar-selection-track]");
expect(activeTrack)
.toHaveClass("origin-left", "scale-x-100", "transition-transform");
expect(activeTrack?.getAttribute("style")).toContain("currentcolor");
rerender(
<ChatList
@@ -23,6 +23,7 @@ describe("generic tool activity semantics", () => {
['generate_image({"prompt":"private launch art"})', "Generated image", ""],
['spawn({"label":"Research competitors","task":"private task"})', "Delegated task", "Research competitors"],
['message({"channel":"telegram","content":"private message"})', "Sent message", "telegram"],
['send_session_message({"to":"@reviewer","content":"private message","expect_reply":true})', "Asked", "@reviewer"],
['my({"action":"check","key":"context_window_tokens"})', "Checked agent settings", "context_window_tokens"],
['my({"action":"set","key":"model","value":"private-model"})', "Updated agent settings", "model"],
['cron({"action":"add","name":"Daily digest","message":"private prompt"})', "Scheduled automation", "Daily digest"],
@@ -40,6 +41,60 @@ describe("generic tool activity semantics", () => {
expect(`${presentation.label} ${presentation.detail}`).not.toMatch(/[{}]|private|tool-results/);
});
it("renders a handle target once and uses plural copy for grouped messages", () => {
const first = parseGenericToolTrace(
'send_session_message({"to":"@kai","content":"first","expect_reply":false})',
)!;
const second = parseGenericToolTrace(
'send_session_message({"to":"@mira","content":"second","expect_reply":false})',
)!;
const single = describeGenericToolRun([{ trace: first, status: "done" }]);
expect([single.label, single.detail].filter(Boolean).join(" ")).toBe("Sent to @kai");
const grouped = describeGenericToolRun([
{ trace: first, status: "done" },
{ trace: second, status: "done" },
]);
expect(grouped).toMatchObject({
label: "Sent messages",
detail: "",
aside: "2 messages",
});
});
it.each([
[true, "running", "Asking"],
[true, "done", "Asked"],
[false, "running", "Sending to"],
[false, "done", "Sent to"],
[false, "error", "Could not reach"],
] as const)(
"describes expect_reply=%s handle activity while %s",
(expectReply, status, label) => {
const presentation = describeRun(
`send_session_message({"to":"@kai","content":"private","expect_reply":${expectReply}})`,
status,
);
expect(presentation).toMatchObject({ label, detail: "@kai" });
},
);
it.each([
["true", "Asked"],
["1", "Asked"],
["yes", "Asked"],
["false", "Sent to"],
["0", "Sent to"],
["no", "Sent to"],
])("matches backend boolean casting for expect_reply=%s", (expectReply, label) => {
const presentation = describeRun(
`send_session_message({"to":"@kai","content":"private","expect_reply":"${expectReply}"})`,
"done",
);
expect(presentation).toMatchObject({ label, detail: "@kai" });
});
it.each([
["running", "Generating image"],
["done", "Generated image"],
@@ -28,6 +28,53 @@ describe("MarkdownTextRenderer", () => {
);
});
it("highlights only known handle handles in prose with their identity color", () => {
render(
<MarkdownTextRenderer
sessionHandles={[{
id: "handle-jules",
name: "jules",
session_key: "websocket:jules",
color_slot: 0,
}]}
>
{"已直接回复 @jules;未知 @ghost;邮箱 hello@jules.test;代码 `@jules`。"}
</MarkdownTextRenderer>,
);
const mention = screen.getByTestId("message-handle-mention-jules");
expect(mention).toHaveTextContent("@jules");
expect(mention).toHaveClass("text-foreground");
expect(mention.parentElement?.getAttribute("style"))
.toContain("var(--session-handle-0)");
expect(mention.closest("a")).toHaveAttribute(
"href",
"#/chat/websocket%3Ajules",
);
expect(screen.getByText("@jules", { selector: "code" })).toBeInTheDocument();
expect(screen.getByText(/未知 @ghost/)).toBeInTheDocument();
expect(screen.getByText(/hello@jules\.test/)).toBeInTheDocument();
expect(screen.getAllByText("@jules")).toHaveLength(2);
});
it("does not highlight handle handles inside raw or normalized HTML", () => {
render(
<MarkdownTextRenderer
sessionHandles={[{
id: "handle-jules",
name: "jules",
session_key: "websocket:jules",
color_slot: 0,
}]}
>
{"<code>@jules</code> <span>@jules</span> <mark>@jules</mark> outside @jules"}
</MarkdownTextRenderer>,
);
expect(screen.getAllByTestId("message-handle-mention-jules")).toHaveLength(1);
expect(screen.getByTestId("message-handle-mention-jules")).toHaveTextContent("@jules");
});
it("does not link non-WebUI session references", () => {
const { container } = render(
<MarkdownTextRenderer>
+107 -6
View File
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import type {
CliAppInfo,
@@ -593,11 +594,11 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
});
it("renders persisted session mentions inside sent user messages", () => {
it("renders new # session references as links", () => {
const message: UIMessage = {
id: "u-session",
role: "user",
content: "Use @收费设计 as context",
content: "Use #收费设计",
createdAt: Date.now(),
sessionMentions: [{
name: "收费设计",
@@ -608,13 +609,113 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} />);
const token = screen.getByTestId("message-session-mention-收费设计");
expect(token).toHaveTextContent("@收费设计");
const token = screen.getByTestId("message-session-reference-收费设计");
expect(token).toHaveTextContent("#收费设计");
expect(token).toHaveAttribute("title", "Session: 收费设计");
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
expect(token.closest("a")?.getAttribute("style")).toContain(
"text-decoration-color: var(--inline-token-highlight)",
});
it("prefers legacy @ session metadata over a same-name catalog capability", () => {
const message: UIMessage = {
id: "u-legacy-session",
role: "user",
content: "Review @zoom",
createdAt: Date.now(),
sessionMentions: [{
name: "zoom",
session_key: "websocket:zoom-notes",
title: "Zoom notes",
}],
};
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
const token = screen.getByTestId("message-session-reference-zoom");
expect(token).toHaveTextContent("@zoom");
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Azoom-notes");
expect(screen.queryByTestId("message-cli-mention-zoom")).not.toBeInTheDocument();
});
it("keeps a new # reference distinct from a structured same-name capability", () => {
const message: UIMessage = {
id: "u-session-and-cli",
role: "user",
content: "Compare #zoom with @zoom",
createdAt: Date.now(),
sessionMentions: [{
name: "zoom",
session_key: "websocket:zoom-notes",
title: "Zoom notes",
}],
cliApps: [{ name: "zoom" }],
};
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
expect(screen.getByTestId("message-session-reference-zoom")).toHaveTextContent("#zoom");
expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
});
it("renders incoming handle input as assistant markdown with session provenance", async () => {
await act(async () => {
await preloadMarkdownText();
});
const message: UIMessage = {
id: "handle-input-1",
role: "user",
content: "**Please verify** the release notes.",
createdAt: Date.now(),
sessionMessage: {
direction: "incoming",
message_id: "handle-message-1",
session: {
id: "handle_reviewer",
name: "reviewer",
color_slot: 4,
session_key: "websocket:reviewer",
},
},
};
const { container } = render(
<MessageBubble message={message} sessionDirectory={[message.sessionMessage!.session]} />,
);
const sessionMessage = container.querySelector('[data-handle-message="incoming"]');
expect(sessionMessage).toHaveClass("w-full");
expect(screen.getByText("Please verify").tagName).toBe("STRONG");
const sessionLink = screen.getByRole("link", { name: "@reviewer" });
expect(sessionLink).toHaveAttribute("href", "#/chat/websocket%3Areviewer");
const sessionRange = sessionMessage?.querySelector("[data-handle-message-body]");
expect(sessionRange).toHaveClass("border-s-2", "rounded-es-[16px]", "ps-2.5");
expect(sessionRange?.getAttribute("style")).toContain("var(--session-handle-4)");
});
it("renders provenance for a deleted handle as plain text", async () => {
await act(async () => {
await preloadMarkdownText();
});
const message: UIMessage = {
id: "handle-input-deleted",
role: "user",
content: "This message remains in history.",
createdAt: Date.now(),
sessionMessage: {
direction: "incoming",
message_id: "handle-message-deleted",
session: {
id: "handle_deleted",
name: "noah",
color_slot: 2,
session_key: "websocket:noah",
},
},
};
render(<MessageBubble message={message} sessionDirectory={[]} />);
expect(screen.getByText("@noah")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "@noah" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
+63 -7
View File
@@ -504,7 +504,7 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenCalledTimes(3);
});
it("records goal_status run strip without an onChat subscriber", () => {
it("records canonical run status without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -527,7 +527,50 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears the local run strip immediately when a stop is requested", () => {
it("starts the run projection immediately when a lifecycle message is submitted", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-13T10:00:00.000Z"));
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-optimistic", "hello", undefined, {
turnId: "turn-optimistic",
});
const submittedAt = Date.now() / 1000;
expect(client.getRunStartedAt("chat-optimistic")).toBe(submittedAt);
expect(handler).toHaveBeenLastCalledWith("chat-optimistic", submittedAt);
expect(client.hasUnsettledRun("chat-optimistic")).toBe(true);
});
it("does not start a separate run projection for side-channel guidance", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-guidance-only", "focus here", undefined, {
turnId: "turn-guidance-only",
startsNewRun: false,
});
expect(client.getRunStartedAt("chat-guidance-only")).toBeNull();
expect(handler).not.toHaveBeenCalled();
});
it("clears the local run status immediately when a stop is requested", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -552,7 +595,7 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
});
it("clears stale run strip when reconnecting after a dropped socket", async () => {
it("clears stale run status when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
@@ -578,7 +621,7 @@ describe("NanobotClient", () => {
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
it("clears run strip when a turn_end arrives without idle", () => {
it("clears run status when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -728,6 +771,7 @@ describe("NanobotClient", () => {
expect(
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
).toBe(true);
expect(client.getRunStartedAt("chat-rejected")).toBeNull();
});
it("does not let an older rejection settle or stop a newer run", () => {
@@ -2062,7 +2106,7 @@ describe("NanobotClient", () => {
);
});
it("includes session mentions in outbound messages", () => {
it("keeps session references and handle mentions separate on the wire", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -2071,23 +2115,35 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-current", "Use @pricing", undefined, {
client.sendMessage("chat-current", "Use #pricing and ask @mira", undefined, {
sessionMentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
sessionHandles: [{
id: "handle_mira",
name: "mira",
session_key: "websocket:mira",
color_slot: 3,
}],
});
expect(lastSocket().sent).toContain(JSON.stringify({
type: "message",
chat_id: "chat-current",
content: "Use @pricing",
content: "Use #pricing and ask @mira",
session_mentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
session_handles: [{
id: "handle_mira",
name: "mira",
session_key: "websocket:mira",
color_slot: 3,
}],
webui: true,
}));
});
+447 -40
View File
@@ -127,7 +127,12 @@ const MCP_PRESETS: McpPresetInfo[] = [
},
];
function session(chatId: string, title: string, preview = ""): ChatSummary {
function session(
chatId: string,
title: string,
preview = "",
mentionName = title,
): ChatSummary {
return {
key: `websocket:${chatId}`,
channel: "websocket",
@@ -136,6 +141,12 @@ function session(chatId: string, title: string, preview = ""): ChatSummary {
updatedAt: null,
title,
preview,
handle: {
id: `handle_${chatId}`,
name: mentionName,
color_slot: 2,
session_key: `websocket:${chatId}`,
},
};
}
@@ -1722,30 +1733,31 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "普通文字 @收费设计", selectionStart: 10 },
target: { value: "普通文字 #收费设计", selectionStart: 10 },
});
expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenLastCalledWith("普通文字 @收费设计", undefined, undefined);
expect(onSend).toHaveBeenLastCalledWith("普通文字 #收费设计", undefined, undefined);
fireEvent.change(input, {
target: { value: "参考 @收费", selectionStart: 6 },
target: { value: "参考 #收费", selectionStart: 6 },
});
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@收费设计/i })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /^收费设计 #收费设计$/i }))
.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("参考 @收费设计 ");
const mention = screen.getByTestId("composer-session-mention-收费设计");
expect(mention).toHaveTextContent("@收费设计");
expect(input).toHaveValue("参考 #收费设计 ");
const mention = screen.getByTestId("composer-session-reference-收费设计");
expect(mention).toHaveTextContent("#收费设计");
expect(mention).toHaveClass("font-normal");
expect(mention).not.toHaveClass("font-[550]");
expect(mention.closest("a")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("参考 @收费设计", undefined, {
expect(onSend).toHaveBeenCalledWith("参考 #收费设计", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
@@ -1754,6 +1766,198 @@ describe("ThreadComposer", () => {
});
});
it("keeps a selected session reference bound across title refreshes", () => {
const onSend = vi.fn();
const target = session("planning", "Plan");
const { rerender } = render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[target]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "#Pla", selectionStart: 4 } });
fireEvent.keyDown(input, { key: "Tab" });
rerender(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[{ ...target, title: "Renamed plan" }]}
/>,
);
expect(screen.getByTestId("composer-session-reference-Plan"))
.toHaveTextContent("#Plan");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("#Plan", undefined, {
sessionMentions: [{
name: "Plan",
session_key: "websocket:planning",
title: "Renamed plan",
}],
});
});
it("does not revive structured session identity after its token is removed", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[session("pricing", "收费设计", "讨论云存储")]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "#收费", selectionStart: 3 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByTestId("composer-session-reference-收费设计")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "", selectionStart: 0 } });
fireEvent.change(input, {
target: { value: "普通文字 #收费设计", selectionStart: 10 },
});
expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("普通文字 #收费设计", undefined, undefined);
});
it("does not migrate a structured identity across an atomic select-all replacement", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[session("pricing", "收费设计", "讨论云存储")]}
/>,
);
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "#收费", selectionStart: 3 } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByTestId("composer-session-reference-收费设计")).toBeInTheDocument();
input.setSelectionRange(0, input.value.length);
fireEvent.select(input);
const replacement = "普通文字 #收费设计";
fireEvent.change(input, {
target: {
value: replacement,
selectionStart: replacement.length,
selectionEnd: replacement.length,
},
});
expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(replacement, undefined, undefined);
});
it("keeps same-name session references distinct from capability mentions", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
sessions={[session("blender-chat", "blender")]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "#blend", selectionStart: 6 } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("composer-session-reference-blender")).toBeInTheDocument();
const next = "#blender @blend";
fireEvent.change(input, { target: { value: next, selectionStart: next.length } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("composer-session-reference-blender")).toBeInTheDocument();
expect(screen.getByTestId("composer-cli-mention-blender")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("#blender @blender", undefined, {
cliApps: [expect.objectContaining({ name: "blender" })],
sessionMentions: [{
name: "blender",
session_key: "websocket:blender-chat",
title: "blender",
}],
});
});
it("drops structured session semantics when the identity leaves the current catalog", () => {
const onSend = vi.fn();
const target = session("pricing", "pricing", "", "pricing");
const { rerender } = render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[target]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "#pricing", selectionStart: 8 } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("composer-session-reference-pricing")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[]}
/>,
);
expect(screen.queryByTestId("composer-session-reference-pricing")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("#pricing", undefined, undefined);
});
it("exposes mention suggestions as an aria-activedescendant combobox and ignores IME Enter", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const combobox = screen.getByRole("combobox", { name: "Message input" });
const listbox = screen.getByRole("listbox", { name: "Mentions" });
const firstOption = screen.getByRole("option", { name: /@gimp/i });
expect(combobox).toHaveAttribute("aria-expanded", "true");
expect(combobox).toHaveAttribute("aria-controls", listbox.id);
expect(combobox).toHaveAttribute("aria-activedescendant", firstOption.id);
expect(firstOption).toHaveAttribute("tabindex", "-1");
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(input).toHaveValue("@");
expect(listbox).toBeInTheDocument();
fireEvent.keyDown(input, { key: "ArrowDown" });
const secondOption = screen.getByRole("option", { name: /@blender/i });
expect(combobox).toHaveAttribute("aria-activedescendant", secondOption.id);
});
it("keeps combobox semantics when the mention popup is closed", () => {
render(<ThreadComposer onSend={vi.fn()} placeholder="Type your message..." />);
const input = screen.getByRole("combobox", { name: "Message input" });
expect(input).toHaveAttribute("aria-autocomplete", "list");
expect(input).toHaveAttribute("aria-expanded", "false");
expect(input).not.toHaveAttribute("aria-controls");
expect(input).not.toHaveAttribute("aria-activedescendant");
});
it("turns a dropped sidebar session into the shared structured mention", () => {
const onSend = vi.fn();
render(
@@ -1782,7 +1986,7 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue("Compare notes");
expect(screen.getByTestId("composer-session-drag-preview"))
.toHaveTextContent("@收费设计");
.toHaveTextContent("#收费设计");
fireEvent.dragEnd(document);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
@@ -1792,13 +1996,13 @@ describe("ThreadComposer", () => {
fireEvent.drop(input, { dataTransfer });
expect(input).toHaveValue("Compare @收费设计 notes");
expect(input).toHaveValue("Compare #收费设计 notes");
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
expect(screen.getByTestId("composer-session-mention-收费设计"))
.toHaveTextContent("@收费设计");
expect(screen.getByTestId("composer-session-reference-收费设计"))
.toHaveTextContent("#收费设计");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
expect(onSend).toHaveBeenCalledWith("Compare #收费设计 notes", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
@@ -1829,17 +2033,19 @@ describe("ThreadComposer", () => {
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
});
it("disambiguates duplicate and capability-colliding session names", () => {
it("uses stable handle identities without exposing session titles", () => {
const handles = [
session("a", "First planning title", "", "Plan"),
session("b", "Second planning title", "", "Plan-2"),
session("blender-chat", "3D notes", "", "Blender"),
];
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
mcpPresets={MCP_PRESETS}
sessions={[
...["a", "b"].map((chatId) => session(chatId, "Plan")),
session("blender-chat", "Blender", "3D notes"),
]}
handleSessions={handles}
/>,
);
@@ -1849,18 +2055,130 @@ describe("ThreadComposer", () => {
const palette = screen.getByRole("listbox", { name: "Mentions" });
expect(within(palette).getAllByRole("group").map((group) => (
group.getAttribute("aria-label")
))).toEqual(["CLI apps", "MCP services", "Nanobot conversations"]);
const options = screen.getAllByRole("option", { name: /Plan @Plan/i });
expect(options.map((option) => option.textContent)).toEqual([
expect.stringContaining("@Plan"),
expect.stringContaining("@Plan-chat"),
]);
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
))).toEqual(["Nanobot conversations", "CLI apps", "MCP services"]);
const firstSession = screen.getByRole("option", { name: /^@Plan$/i });
expect(firstSession).toHaveAttribute("aria-selected", "true");
expect(input).toHaveAttribute("aria-activedescendant", firstSession.id);
expect(screen.getByRole("option", { name: /^@Plan-2$/i }))
.toBeInTheDocument();
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 }))
expect(screen.getByRole("option", { name: /^@Blender$/i }))
.toBeInTheDocument();
expect(screen.getByRole("option", { name: /Blender @blender Use/i }))
.toBeInTheDocument();
expect(screen.queryByText("First planning title")).not.toBeInTheDocument();
expect(screen.queryByText("Second planning title")).not.toBeInTheDocument();
});
it("binds every same-name occurrence to one selected namespace across queue replay", () => {
const onSend = vi.fn();
const sameNameSession = session("blender-handle", "Session title", "", "blender");
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
cliApps={CLI_APPS}
handleSessions={[sameNameSession]}
/>,
);
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "@blend", selectionStart: 6 } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("composer-handle-mention-blender")).toBeInTheDocument();
const withSecondOccurrence = "@blender then @blender";
fireEvent.change(input, {
target: { value: withSecondOccurrence, selectionStart: withSecondOccurrence.length },
});
expect(screen.getAllByTestId("composer-handle-mention-blender")).toHaveLength(2);
input.setSelectionRange("@blender then ".length, withSecondOccurrence.length);
fireEvent.select(input);
fireEvent.change(input, {
target: { value: "@blender then @blend", selectionStart: 20 },
});
const cliOption = screen.getByRole("option", { name: /Blender @blender .* CLI/i });
fireEvent.mouseDown(cliOption);
expect(screen.getAllByTestId("composer-cli-mention-blender")).toHaveLength(2);
expect(screen.queryByTestId("composer-handle-mention-blender")).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("@blender then @blender", undefined, {
cliApps: [expect.objectContaining({ name: "blender" })],
continueActiveTurn: true,
});
});
it("does not reinterpret a disappeared handle as a same-name CLI app", () => {
const onSend = vi.fn();
const handle = session("blender-handle", "Session title", "", "blender");
const { rerender } = render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
handleSessions={[handle]}
/>,
);
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "@blend", selectionStart: 6 } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByTestId("composer-handle-mention-blender")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
handleSessions={[]}
/>,
);
expect(screen.queryByTestId("composer-handle-mention-blender")).not.toBeInTheDocument();
expect(screen.queryByTestId("composer-cli-mention-blender")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("@blender", undefined, undefined);
});
it("supports a prototype-named MCP through live and queued mention parsing", () => {
const onSend = vi.fn();
const constructorPreset: McpPresetInfo = {
...MCP_PRESETS[0],
name: "constructor",
display_name: "Constructor",
};
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
mcpPresets={[constructorPreset]}
/>,
);
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, {
target: { value: "use @constructor", selectionStart: 16 },
});
expect(screen.getByTestId("composer-mcp-mention-constructor")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("use @constructor")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("use @constructor", undefined, {
mcpPresets: [expect.objectContaining({ name: "constructor" })],
continueActiveTurn: true,
});
});
it("releases the eight-session limit when a mention is removed", () => {
@@ -1878,11 +2196,21 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
for (let index = 0; index < 8; index += 1) {
const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
const value = `${input.value}${input.value ? " " : ""}#Topic${index}`;
input.setSelectionRange(input.value.length, input.value.length);
fireEvent.select(input);
fireEvent.change(input, { target: { value, selectionStart: value.length } });
fireEvent.keyDown(input, { key: "Tab" });
}
const replacement = `${input.value.replace("@Topic0 ", "")} @Topic8`;
const withoutFirst = input.value.replace("#Topic0 ", "");
input.setSelectionRange(0, "#Topic0 ".length);
fireEvent.select(input);
fireEvent.change(input, {
target: { value: withoutFirst, selectionStart: 0 },
});
const replacement = `${withoutFirst}#Topic8`;
input.setSelectionRange(withoutFirst.length, withoutFirst.length);
fireEvent.select(input);
fireEvent.change(input, {
target: { value: replacement, selectionStart: replacement.length },
});
@@ -1896,7 +2224,7 @@ describe("ThreadComposer", () => {
))).toEqual(expect.arrayContaining(["websocket:topic-8"]));
});
it("keeps a selected session stable across refreshes and queued guidance", () => {
it("keeps a selected handle mention when queuing guidance for the active turn", () => {
const onSend = vi.fn();
const target = session("z-target", "Plan", "Original plan");
const { rerender } = render(
@@ -1905,7 +2233,7 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
sessions={[target]}
handleSessions={[target]}
/>,
);
@@ -1919,22 +2247,26 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
sessions={[
handleSessions={[
{ ...target, title: "Renamed plan" },
session("a-new", "Plan", target.preview),
session("a-new", "Another title", target.preview, "Other"),
]}
/>,
);
expect(screen.getByTestId("composer-session-mention-Plan")).toHaveTextContent("@Plan");
expect(screen.getByTestId("composer-handle-mention-Plan")).toHaveTextContent("@Plan");
fireEvent.keyDown(input, { key: "Enter" });
expect(
within(screen.getByRole("group", { name: "Queued guidance" })).getByText("@Plan"),
).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("@Plan", undefined, {
sessionMentions: [{
sessionHandles: [{
id: "handle_z-target",
name: "Plan",
session_key: "websocket:z-target",
title: "Plan",
color_slot: 2,
}],
continueActiveTurn: true,
});
@@ -1993,6 +2325,49 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue(`please use $${skillName} `);
});
it("keeps a later session occurrence bound while completing an earlier skill", () => {
const onSend = vi.fn();
const skillName = "arxiv-intelligence-filter";
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[session("plan", "Plan")]}
skills={[{
name: skillName,
description: "Research papers",
source: "builtin",
enabled: true,
available: true,
}]}
/>,
);
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "#Pla", selectionStart: 4 } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByTestId("composer-session-reference-Plan")).toBeInTheDocument();
input.setSelectionRange(0, 0);
fireEvent.select(input);
const withSkillQuery = `$arx ${input.value}`;
fireEvent.change(input, {
target: { value: withSkillQuery, selectionStart: 4, selectionEnd: 4 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue(`$${skillName} #Plan `);
expect(screen.getByTestId("composer-session-reference-Plan")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(`$${skillName} #Plan`, undefined, {
sessionMentions: [{
name: "Plan",
session_key: "websocket:plan",
title: "Plan",
}],
});
});
it("ranks skill name matches ahead of earlier description matches", () => {
render(
<ThreadComposer
@@ -3043,6 +3418,38 @@ describe("ThreadComposer", () => {
});
});
it("migrates queued guidance from the v1 storage key without losing the prompt", async () => {
const legacyKey = "nanobot.webui.composerQueuedGuidance.v1:chat-a";
const currentKey = "nanobot.webui.composerQueuedGuidance.v2:chat-a";
window.localStorage.setItem(legacyKey, JSON.stringify([{
id: "legacy-guidance",
text: "keep this older queued prompt",
sessionMentions: [{
name: "old-handle",
session_key: "websocket:old-handle",
title: "Old handle",
}],
}]));
render(
<ThreadComposer
onSend={vi.fn()}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
expect(await screen.findByText("keep this older queued prompt")).toBeInTheDocument();
expect(window.localStorage.getItem(legacyKey)).toBeNull();
expect(JSON.parse(window.localStorage.getItem(currentKey) ?? "[]"))
.toEqual([expect.objectContaining({
id: "legacy-guidance",
text: "keep this older queued prompt",
})]);
});
it("keeps temporary chat guidance in memory only", async () => {
const onSend = vi.fn();
const view = render(
@@ -3062,7 +3469,7 @@ describe("ThreadComposer", () => {
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
expect(
window.localStorage.getItem(
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
"nanobot.webui.composerQueuedGuidance.v2:temporary-private",
),
).toBeNull();
@@ -3082,7 +3489,7 @@ describe("ThreadComposer", () => {
});
expect(
window.localStorage.getItem(
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
"nanobot.webui.composerQueuedGuidance.v2:temporary-private",
),
).toBeNull();
});
+255 -41
View File
@@ -21,6 +21,7 @@ function makeClient() {
(modelName: string | null, modelPreset?: string | null) => void
>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const runStartedAtByChatId = new Map<string, number>();
const runGenerationByChatId = new Map<string, number>();
const latestRunTurnIdByChatId = new Map<string, string>();
@@ -108,6 +109,13 @@ function makeClient() {
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
getRunTurnId: (chatId: string) => latestRunTurnIdByChatId.get(chatId) ?? null,
onRunStatus: (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
for (const [chatId, startedAt] of runStartedAtByChatId) handler(chatId, startedAt);
return () => {
runStatusHandlers.delete(handler);
};
},
finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.delete(chatId);
@@ -417,6 +425,164 @@ describe("ThreadShell", () => {
);
});
it("keeps the current handle handle visible in the thread header", async () => {
const client = makeClient();
const currentSession = {
...session("handle-handle"),
handle: {
id: "handle-current",
name: "mira",
session_key: "websocket:handle-handle",
color_slot: 3,
},
};
render(wrap(
client,
<ThreadShell
session={currentSession}
title="A title that may change independently"
onToggleSidebar={() => {}}
/>,
));
const handle = await screen.findByTestId("thread-handle-handle");
expect(handle).toHaveTextContent("@mira");
expect(handle.querySelector("[aria-hidden]")).toBeNull();
const headerDecoration = handle.querySelector("span[style*='border-bottom-color']");
expect(headerDecoration?.getAttribute("style"))
.toContain("var(--session-handle-3)");
expect(headerDecoration?.querySelector(".text-foreground"))
.toHaveClass("text-foreground");
});
it("pins each handle identity inside its workbench pane", async () => {
const client = makeClient();
const currentSession = {
...session("pane-handle"),
handle: {
id: "handle-pane",
name: "kai",
session_key: "websocket:pane-handle",
color_slot: 2,
},
};
render(wrap(
client,
<ThreadShell
session={currentSession}
title="Investigate incoming messages"
onToggleSidebar={() => {}}
hideHeaderTitle
headerActive={false}
/>,
));
expect(screen.queryByTestId("thread-handle-handle")).not.toBeInTheDocument();
const identity = await screen.findByTestId("pane-handle-identity");
expect(identity).toHaveAttribute("data-active", "false");
expect(identity).toHaveAttribute("aria-label", "Session @kai");
expect(identity.querySelector("[data-pane-handle-handle]")).toHaveTextContent("@kai");
expect(identity.querySelector("[aria-hidden]")).toBeNull();
const paneDecoration = identity.querySelector(
"[data-pane-handle-handle] span[style*='border-bottom-color']",
);
expect(paneDecoration?.getAttribute("style")).toContain("var(--session-handle-2)");
const paneText = paneDecoration?.querySelector(".text-foreground");
expect(paneText).toHaveClass("text-foreground");
expect(paneText).not.toHaveClass("opacity-80");
expect(identity).not.toHaveTextContent("Investigate incoming messages");
expect(identity.className).not.toContain("bg-");
expect(identity.className).not.toContain("border-");
});
it("sends a structured handle mention through the focused thread", async () => {
const client = makeClient();
const source = {
...session("source"),
handle: {
id: "handle_00000000000000000000000000000001",
name: "source",
session_key: "websocket:source",
color_slot: 1,
},
};
const reviewer = {
...session("reviewer"),
handle: {
id: "handle_00000000000000000000000000000002",
name: "reviewer",
session_key: "websocket:reviewer",
color_slot: 2,
},
};
render(wrap(
client,
<ThreadShell
session={source}
sessions={[source, reviewer]}
title="Source"
onToggleSidebar={() => {}}
/>,
));
const input = await screen.findByLabelText("Message input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "@rev", selectionStart: 4 } });
fireEvent.keyDown(input, { key: "Tab" });
const message = `${input.value}check this`;
fireEvent.change(input, { target: { value: message, selectionStart: message.length } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(client.sendMessage).toHaveBeenCalledWith(
source.chatId,
message,
undefined,
expect.objectContaining({
sessionHandles: [reviewer.handle],
turnId: expect.any(String),
}),
);
});
it("offers the focused session's own handle handle as a structured mention", async () => {
const client = makeClient();
const source = {
...session("source-self"),
handle: {
id: "handle_00000000000000000000000000000003",
name: "bea",
session_key: "websocket:source-self",
color_slot: 3,
},
};
render(wrap(
client,
<ThreadShell
session={source}
sessions={[source]}
title="Source"
onToggleSidebar={() => {}}
/>,
));
const input = await screen.findByLabelText("Message input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "@be", selectionStart: 3 } });
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(client.sendMessage).toHaveBeenCalledWith(
source.chatId,
"@bea",
undefined,
expect.objectContaining({
sessionHandles: [source.handle],
turnId: expect.any(String),
}),
);
});
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
await preloadMarkdownText();
const client = makeClient();
@@ -787,7 +953,7 @@ describe("ThreadShell", () => {
fireEvent.click(badge);
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
@@ -943,6 +1109,39 @@ describe("ThreadShell", () => {
});
});
it("does not offer persisted sessions inside a temporary chat", async () => {
const client = makeClient();
const handle = {
...session("handle"),
title: "Reviewer",
handle: {
id: "handle_11111111111111111111111111111111",
name: "reviewer",
color_slot: 3,
session_key: "websocket:handle",
},
};
render(wrap(
client,
<ThreadShell
session={session("temporary")}
sessions={[handle]}
title="Temporary chat"
temporary
temporaryChatIds={["temporary"]}
onToggleSidebar={() => {}}
/>,
));
const input = await screen.findByLabelText("Message input");
await act(async () => {
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
});
expect(screen.queryByRole("group", { name: "Nanobot conversations" }))
.not.toBeInTheDocument();
});
it("highlights sent skill references without skill metadata", async () => {
const client = makeClient();
render(wrap(
@@ -2052,7 +2251,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(historyCalls).toBe(1));
const input = screen.getByRole("textbox", { name: "Message input" });
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "rejected local turn" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
@@ -2289,7 +2488,7 @@ describe("ThreadShell", () => {
act(() => client._emitSessionUpdate("chat-version-a"));
await waitFor(() => expect(chatACalls).toBe(2));
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: "new question" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -2390,7 +2589,7 @@ describe("ThreadShell", () => {
turn_id: newTurnId,
});
});
const input = screen.getByRole("textbox", { name: "Message input" });
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued for the new run" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2689,7 +2888,7 @@ describe("ThreadShell", () => {
turn_id: turnId,
});
});
const input = screen.getByRole("textbox", { name: "Message input" });
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2792,7 +2991,7 @@ describe("ThreadShell", () => {
});
});
await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument());
const input = screen.getByRole("textbox", { name: "Message input" });
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("queued guidance")).toBeInTheDocument();
@@ -2888,7 +3087,7 @@ describe("ThreadShell", () => {
});
await waitFor(() => expect(screen.getByText("Continuing the search.")).toBeInTheDocument());
const input = screen.getByRole("textbox", { name: "Message input" });
const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "How is it going?" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -3930,41 +4129,56 @@ describe("ThreadShell", () => {
);
});
it("offers only same-project sessions in restricted mode", async () => {
const client = makeClient();
const currentScope = {
project_path: "/projects/current",
access_mode: "restricted" as const,
};
const sameProject = {
...session("same-project"),
title: "Same project",
workspaceScope: currentScope,
};
const otherProject = {
...session("other-project"),
title: "Other project",
workspaceScope: {
project_path: "/projects/other",
access_mode: "restricted" as const,
},
};
it.each(["restricted", "full"] as const)(
"offers routable sessions across projects in %s mode",
async (accessMode) => {
const client = makeClient();
const currentScope = {
project_path: "/projects/current",
access_mode: accessMode,
};
const sameProject = {
...session("same-project"),
title: "Same project",
workspaceScope: currentScope,
handle: {
id: "handle_same_project",
name: "same-project",
color_slot: 1,
session_key: "websocket:same-project",
},
};
const otherProject = {
...session("other-project"),
title: "Other project",
workspaceScope: {
project_path: "/projects/other",
access_mode: accessMode,
},
handle: {
id: "handle_other_project",
name: "other-project",
color_slot: 2,
session_key: "websocket:other-project",
},
};
render(wrap(
client,
<ThreadShell
session={session("current")}
sessions={[sameProject, otherProject]}
title="Current"
onToggleSidebar={() => {}}
workspaceScope={currentScope}
/>,
));
render(wrap(
client,
<ThreadShell
session={session("current")}
sessions={[sameProject, otherProject]}
title="Current"
onToggleSidebar={() => {}}
workspaceScope={currentScope}
/>,
));
const input = await screen.findByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const input = await screen.findByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("option", { name: /Same project/i })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /Other project/i })).not.toBeInTheDocument();
});
expect(screen.getByRole("option", { name: /^@same-project$/i })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /^@other-project$/i })).toBeInTheDocument();
},
);
});
+1 -1
View File
@@ -215,7 +215,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
}
describe("ThreadViewport", () => {
it("keeps reasoning disclosure anchored for pointer and keyboard toggles", () => {
it("keeps unmanaged reasoning disclosure anchored for pointer and keyboard toggles", () => {
const takeUserControl = vi.spyOn(
ThreadMotionCoordinator.prototype,
"takeUserControl",
+132
View File
@@ -38,6 +38,8 @@ const SEMANTIC_MESSAGE_FIELDS = [
"cliApps",
"mcpPresets",
"sessionMentions",
"sessionHandles",
"handle",
"reasoning",
"latencyMs",
"source",
@@ -70,6 +72,7 @@ function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const errorHandlers = new Set<(error: StreamError) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const runStartedAtByChatId = new Map<string, number>();
const unsettledRunByChatId = new Map<string, boolean>();
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
@@ -113,6 +116,13 @@ function fakeClient() {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
onRunStatus(handler: (chatId: string, startedAt: number | null) => void) {
runStatusHandlers.add(handler);
for (const [chatId, startedAt] of runStartedAtByChatId) {
handler(chatId, startedAt);
}
return () => runStatusHandlers.delete(handler);
},
getRunStartedAt(chatId: string) {
const v = runStartedAtByChatId.get(chatId);
return v === undefined ? null : v;
@@ -154,6 +164,11 @@ function fakeClient() {
emitError(error: StreamError) {
errorHandlers.forEach((handler) => handler(error));
},
emitRunStatus(chatId: string, startedAt: number | null) {
if (startedAt === null) runStartedAtByChatId.delete(chatId);
else runStartedAtByChatId.set(chatId, startedAt);
runStatusHandlers.forEach((handler) => handler(chatId, startedAt));
},
setUnsettled(chatId: string, unsettled: boolean) {
unsettledRunByChatId.set(chatId, unsettled);
},
@@ -182,6 +197,101 @@ async function flushStreamFrame() {
}
describe("useNanobotStream", () => {
it("keeps a handle mention on the focused chat's optimistic and outbound turn", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-source", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
const reviewer = {
id: "handle_00000000000000000000000000000001",
name: "reviewer",
session_key: "websocket:chat-reviewer",
color_slot: 3,
};
act(() => {
result.current.send("@reviewer check this", undefined, {
sessionHandles: [reviewer],
});
});
expect(result.current.messages).toEqual([
expect.objectContaining({
role: "user",
content: "@reviewer check this",
deliveryStatus: "sending",
sessionHandles: [reviewer],
}),
]);
expect(result.current.isStreaming).toBe(true);
expect(fake.client.sendMessage).toHaveBeenCalledWith(
"chat-source",
"@reviewer check this",
undefined,
expect.objectContaining({
sessionHandles: [reviewer],
turnId: expect.any(String),
}),
);
});
it("renders an incoming handle message before the target model responds", async () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-handle", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
const sessionMessageEvent: InboundEvent = {
event: "session_message",
chat_id: "chat-handle",
text: "What did you change?",
created_at_ms: 1_234,
turn_id: "handle-turn-1",
turn_phase: "user",
session_message: {
direction: "incoming",
message_id: "handle-message-1",
session: {
id: "handle_11111111111111111111111111111111",
name: "kai",
session_key: "websocket:source",
color_slot: 2,
},
},
};
act(() => {
fake.emit("chat-handle", sessionMessageEvent);
fake.emit("chat-handle", sessionMessageEvent);
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
id: "session-message:handle-message-1",
role: "user",
content: "What did you change?",
createdAt: 1_234,
turnId: "handle-turn-1",
turnPhase: "user",
sessionMessage: sessionMessageEvent.session_message,
});
expect(result.current.isStreaming).toBe(true);
act(() => fake.emit("chat-handle", {
event: "delta",
chat_id: "chat-handle",
text: "I changed",
turn_id: "handle-turn-1",
}));
await flushStreamFrame();
expect(result.current.messages.map((message) => message.role)).toEqual([
"user",
"assistant",
]);
});
it("batches answer deltas into one animation-frame update", async () => {
const fake = fakeClient();
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
@@ -2865,6 +2975,28 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
});
it("clears the pane timer when canonical reconciliation settles the client run", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-g", {
event: "goal_status",
chat_id: "chat-g",
status: "running",
started_at: 1700,
turn_id: "handle:turn-1",
});
});
expect(result.current.runStartedAt).toBe(1700);
act(() => fake.emitRunStatus("chat-g", null));
expect(result.current.runStartedAt).toBeNull();
});
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(