feat(webui): add lightweight session messaging via mentions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user