feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren
2026-05-30 23:45:26 +08:00
committed by GitHub
parent b2e43955e3
commit 3dcf511c84
65 changed files with 4526 additions and 1428 deletions
@@ -736,7 +736,7 @@ describe("AgentActivityCluster", () => {
fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i }));
expect(screen.getByText("Shell")).toBeInTheDocument();
expect(screen.getByText("Command")).toBeInTheDocument();
expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument();
expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument();
expect(screen.queryByText(/for id in/)).not.toBeInTheDocument();
@@ -936,4 +936,67 @@ describe("AgentActivityCluster", () => {
restoreMotion();
}
});
it("renders tool event embeds as inline activity evidence", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-evidence",
role: "tool",
kind: "trace",
content: 'web_fetch({"url":"https://example.com"})',
traces: ['web_fetch({"url":"https://example.com"})'],
toolEvents: [{
phase: "end",
call_id: "call-fetch",
name: "web_fetch",
arguments: { url: "https://example.com" },
embeds: [{
url: "/api/media/signed/screenshot.png",
name: "Homepage screenshot",
type: "image/png",
}],
}],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Web")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Homepage screenshot" })).toHaveAttribute(
"src",
"/api/media/signed/screenshot.png",
);
});
it("shows missing evidence as a file-safe placeholder", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-missing-evidence",
role: "tool",
kind: "trace",
content: 'screenshot({"path":"missing.png"})',
traces: ['screenshot({"path":"missing.png"})'],
toolEvents: [{
phase: "end",
call_id: "call-shot",
name: "screenshot",
arguments: { path: "missing.png" },
files: [{ name: "missing.png", type: "image/png" }],
}],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByText("Vision")).toBeInTheDocument();
expect(screen.getByTestId("activity-evidence-preview")).toBeInTheDocument();
expect(screen.getByText("missing.png")).toBeInTheDocument();
});
});
+12
View File
@@ -5,6 +5,7 @@ import {
deleteSession,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
fetchSidebarState,
fetchWebuiThread,
fetchWorkspaces,
@@ -165,6 +166,17 @@ describe("webui API helpers", () => {
);
});
it("fetches provider model lists", async () => {
await fetchProviderModels("tok", "deepseek");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=deepseek",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
+93 -12
View File
@@ -540,6 +540,58 @@ describe("App layout", () => {
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
});
it("does not show a completed dot later when the active session finishes", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Active work",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Other chat",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^Active work$/ }),
).toBeInTheDocument(),
);
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Active work$/ }));
});
await waitFor(() => expect(document.title).toContain("Active work"));
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345);
});
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null);
});
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
});
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
});
it("restores sidebar run indicators after a page reload", async () => {
mockSessions = [
{
@@ -590,7 +642,22 @@ describe("App layout", () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/api/settings")) {
const href = String(input);
if (href === "/api/settings/provider-models?provider=openai") {
return jsonResponse({
provider: "openai",
label: "OpenAI",
status: "available",
catalog_kind: "official",
models: [
{ id: "openai/gpt-4o", owned_by: "openai", context_window: 128000 },
{ id: "openai/gpt-4o-mini", owned_by: "openai", context_window: 128000 },
],
model_count: 2,
fetched_at: 1,
});
}
if (href.includes("/api/settings")) {
return {
ok: true,
status: 200,
@@ -822,9 +889,9 @@ describe("App layout", () => {
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current model")).toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: /openai\/gpt-4o/ }));
fireEvent.pointerDown(screen.getAllByRole("button", { name: /openai\/gpt-4o/ })[0]);
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
const modelDialog = screen.getByRole("dialog", { name: "New model configuration" });
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
@@ -837,33 +904,47 @@ describe("App layout", () => {
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
expect(modelInput).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
const openModelPicker = () => {
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
};
openModelPicker();
await screen.findByText("openai/gpt-4o-mini");
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
"text-blue-600",
);
fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
await screen.findByText("openai/gpt-4o");
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI"));
const clickProviderRow = (label: string) => {
const providerLabel = screen
.getAllByText(label)
.find((element) => element.className.includes("font-semibold"));
expect(providerLabel).toBeTruthy();
fireEvent.click(providerLabel!);
};
clickProviderRow("OpenAI");
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" },
});
fireEvent.click(screen.getByText("OpenRouter"));
fireEvent.click(screen.getByText("OpenAI"));
clickProviderRow("OpenRouter");
clickProviderRow("OpenAI");
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Ant Ling"));
clickProviderRow("Ant Ling");
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
clickProviderRow("Atomic Chat");
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
+3 -1
View File
@@ -204,7 +204,9 @@ describe("ChatList", () => {
/>,
);
expect(screen.getAllByLabelText("Agent finished")).toHaveLength(1);
const finished = screen.getAllByLabelText("Agent finished");
expect(finished).toHaveLength(1);
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
});
it("folds long default workspace chats and can show all", () => {
+1
View File
@@ -45,6 +45,7 @@ describe("CodeBlock", () => {
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument();
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
});
it("reads theme from context without creating per-block observers", async () => {
+121 -1
View File
@@ -4,6 +4,30 @@ import { describe, expect, it } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => {
it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace\n```"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
expect(container.querySelector("pre div")).toBeNull();
});
it("keeps streaming unfinished fenced code blocks to a single shell", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
{"当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace"}
</MarkdownTextRenderer>,
);
expect(screen.getByText("/Users/renxubin/.nanobot/workspace")).toBeInTheDocument();
expect(container.querySelectorAll("pre")).toHaveLength(1);
expect(container.querySelector("pre div")).toBeNull();
});
it("renders markdown images as inline previews", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
@@ -13,7 +37,6 @@ describe("MarkdownTextRenderer", () => {
"href",
"/api/media/sig/payload",
);
expect(screen.getByText("Diagram")).toBeInTheDocument();
});
it("renders markdown videos as inline players", () => {
@@ -25,4 +48,101 @@ describe("MarkdownTextRenderer", () => {
expect(video).toHaveAttribute("controls");
expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument();
});
it("renders markdown links with file-looking names as file attachments", () => {
render(<MarkdownTextRenderer>![index.html](/api/media/sig/html)</MarkdownTextRenderer>);
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
});
it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
expect(screen.getByRole("img", { name: "Diagram" })).toHaveAttribute(
"src",
"/api/media/sig/payload",
);
expect(screen.getByRole("link", { name: "Open Diagram" })).toHaveAttribute(
"href",
"/api/media/sig/payload",
);
expect(screen.queryByRole("button", { name: "Code" })).not.toBeInTheDocument();
});
it("renders a safe subset of inline HTML", () => {
const { container } = render(
<MarkdownTextRenderer>
{"<mark>高亮文本</mark>\n\n上标:x<sup>2</sup>\n下标:H<sub>2</sub>O"}
</MarkdownTextRenderer>,
);
expect(container.querySelector("mark")).toHaveTextContent("高亮文本");
expect(container.querySelector("sup")).toHaveTextContent("2");
expect(container.querySelector("sub")).toHaveTextContent("2");
});
it("keeps unsafe HTML as text", () => {
const { container } = render(
<MarkdownTextRenderer>
{"<script>alert(1)</script>\n\n<mark onclick=\"alert(1)\">bad</mark>"}
</MarkdownTextRenderer>,
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("mark")).toBeNull();
expect(container).toHaveTextContent("<script>alert(1)</script>");
expect(container).toHaveTextContent("<mark onclick=\"alert(1)\">bad</mark>");
});
it("renders safe details blocks", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"<details><summary>点击展开更多内容</summary>\n\n这里是被折叠的内容。\n\n- 可以放列表\n\n</details>"
}
</MarkdownTextRenderer>,
);
expect(container.querySelector("details")).toBeInTheDocument();
expect(container.querySelector("summary")).toHaveTextContent("点击展开更多内容");
expect(screen.getByText("这里是被折叠的内容。")).toBeInTheDocument();
expect(screen.getByText("可以放列表")).toBeInTheDocument();
expect(container).not.toHaveTextContent("</details>");
});
it("renders task list checkboxes as quiet status marks", () => {
const { container } = render(
<MarkdownTextRenderer>
{"- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果"}
</MarkdownTextRenderer>,
);
expect(container.querySelectorAll("input[type='checkbox']")).toHaveLength(0);
expect(screen.getAllByTestId("markdown-task-checkbox")).toHaveLength(3);
expect(container.querySelectorAll(".task-list-item")).toHaveLength(3);
});
it("keeps dollar amounts from being parsed as inline math", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding."
}
</MarkdownTextRenderer>,
);
expect(container).toHaveTextContent(
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding.",
);
expect(container.querySelector(".katex")).toBeNull();
});
it("still renders explicit math blocks", () => {
const { container } = render(
<MarkdownTextRenderer>{"$$x^2 + y^2 = z^2$$"}</MarkdownTextRenderer>,
);
expect(container.querySelector(".katex")).toBeInTheDocument();
});
});
+27 -1
View File
@@ -42,7 +42,7 @@ describe("MarkdownText", () => {
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
"true",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
@@ -79,4 +79,30 @@ describe("MarkdownText", () => {
vi.useRealTimers();
}
});
it("keeps very large streaming snippets plain until the final render", async () => {
rendererSpy.mockClear();
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
const { rerender } = render(
<MarkdownText streaming>{largeCode}</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
rerender(<MarkdownText>{largeCode}</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
});
});
+46
View File
@@ -236,7 +236,10 @@ describe("MessageBubble", () => {
const video = screen.getByLabelText(/video attachment/i);
expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(video).toHaveAttribute("preload", "auto");
expect(container.querySelector("video[controls]")).toBeInTheDocument();
expect(screen.queryByText("Preview")).not.toBeInTheDocument();
expect(screen.queryByText("Code")).not.toBeInTheDocument();
});
it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
@@ -366,4 +369,47 @@ describe("MessageBubble", () => {
expect(imageButton).not.toHaveAttribute("title");
expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
});
it("renders mislabeled html assistant media as a file attachment", () => {
const message: UIMessage = {
id: "a-html",
role: "assistant",
content: "file ready",
createdAt: Date.now(),
media: [
{
kind: "image",
url: "/api/media/sig/html",
name: "index.html",
},
],
};
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
expect(container.querySelector("img")).not.toBeInTheDocument();
});
it("renders assistant svg media as an image preview", () => {
const message: UIMessage = {
id: "a-svg",
role: "assistant",
content: "chart ready",
createdAt: Date.now(),
media: [
{
kind: "file",
url: "/api/media/sig/svg",
name: "growth.svg",
},
],
};
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByRole("button", { name: /view image: growth.svg/i })).toBeInTheDocument();
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
});
});
+2 -1
View File
@@ -35,8 +35,9 @@ describe("provider brand logos", () => {
expect(providerBrand("zhipu")?.initials).toBe("Z");
});
it("uses official first-party assets for LongCat and Xiaomi MIMO", () => {
it("uses official first-party assets for LongCat, Step Fun, and Xiaomi MIMO", () => {
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
expect(providerBrand("stepfun")?.logoUrls[0]).toBe("https://www.stepfun.com/step_favicon.svg");
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
});
+98
View File
@@ -245,6 +245,104 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("loads provider models and lets users choose one without typing the id manually", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
],
};
const updatedPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: "deepseek-reasoner",
},
model_presets: [
{
...payload.model_presets[0],
model: "deepseek-reasoner",
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/provider-models?provider=deepseek") {
return jsonResponse({
provider: "deepseek",
label: "DeepSeek",
status: "available",
catalog_kind: "official",
models: [
{ id: "deepseek-chat", owned_by: "deepseek", context_window: 65536 },
{ id: "deepseek-reasoner", owned_by: "deepseek", context_window: 65536 },
],
model_count: 2,
fetched_at: 1,
});
}
if (url === "/api/settings/update?model_preset=default&model=deepseek-reasoner") {
return jsonResponse(updatedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=deepseek",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=deepseek-reasoner",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+409 -56
View File
@@ -4,6 +4,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
vi.mock("@/lib/imageEncode", () => ({
encodeImage: vi.fn(async (file: File) => ({
ok: true,
dataUrl: `data:${file.type || "image/png"};base64,aW1hZ2U=`,
bytes: Math.max(1, file.size),
normalized: false,
})),
}));
const COMMANDS: SlashCommand[] = [
{
command: "/stop",
@@ -113,6 +122,17 @@ const MCP_PRESETS: McpPresetInfo[] = [
];
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
function mockBlobUrls() {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:composer-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
}
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, "nanobotHost");
@@ -160,6 +180,9 @@ describe("ThreadComposer", () => {
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[78px]");
expect(input.className).toContain("pt-[27px]");
fireEvent.change(input, { target: { value: "1" } });
expect(input.className).toContain("pt-[27px]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
});
@@ -361,6 +384,8 @@ describe("ThreadComposer", () => {
const status = screen.getByRole("status");
expect(status).toHaveTextContent(/Running/);
expect(status).toHaveTextContent(/2:05/);
expect(status.parentElement).toHaveClass("composer-status-strip");
expect(status.parentElement).toHaveAttribute("data-state", "enter");
vi.useRealTimers();
});
@@ -802,7 +827,7 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("sends image generation mode with automatic aspect ratio", () => {
it("keeps image generation mode out of the composer chrome", () => {
const onSend = vi.fn();
render(
<ThreadComposer
@@ -811,18 +836,14 @@ describe("ThreadComposer", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument();
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(
"Draw a friendly robot",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: null } },
);
expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined);
});
it("shows a stop button while streaming", () => {
@@ -842,75 +863,407 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
});
it("lets users select a concrete image aspect ratio", () => {
it("queues plain guidance while a task is running", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"bottom-full",
);
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a banner" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
fireEvent.change(input, { target: { value: "keep the UI minimal" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith(
"Draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
expect(onSend).not.toHaveBeenCalled();
expect(input).toHaveValue("");
expect(screen.getByText("keep the UI minimal")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("keep the UI minimal");
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
});
it("opens the hero image aspect menu downward", () => {
render(
it("keeps queued guidance attached to the composer and sends it one item at a time", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
imageMode
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"top-full",
const queue = screen.getByRole("group", { name: "Queued guidance" });
expect(queue).toHaveClass("composer-status-strip");
expect(queue).toHaveClass("mx-3");
expect(queue.parentElement?.className).toContain("group/composer");
expect(within(queue).getByText("first follow-up")).toBeInTheDocument();
expect(within(queue).getByText("second follow-up")).toBeInTheDocument();
expect(within(queue).getAllByRole("button", { name: "Edit guidance" })).toHaveLength(2);
expect(within(queue).getAllByRole("button", { name: "Guide" })).toHaveLength(2);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("first follow-up");
});
expect(onSend).toHaveBeenCalledTimes(1);
expect(screen.queryByText("first follow-up")).not.toBeInTheDocument();
expect(screen.getByText("second follow-up")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenLastCalledWith("second follow-up");
});
expect(onSend).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
});
it("dismisses the image aspect menu on outside click, escape, and wheel", () => {
it("lets users edit queued guidance before it is sent", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "rough follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const editButton = screen.getByRole("button", { name: "Edit guidance" });
fireEvent.click(editButton);
await waitFor(() => {
expect(input).toHaveFocus();
});
expect(input).toHaveValue("rough follow-up");
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "polished follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("polished follow-up");
});
});
it("requeues edited guidance at the end of the pending list", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getAllByRole("button", { name: "Edit guidance" })[0]);
await waitFor(() => {
expect(input).toHaveValue("first follow-up");
});
fireEvent.change(input, { target: { value: "first follow-up edited" } });
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
expect(onSend).toHaveBeenCalledTimes(1);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenLastCalledWith("first follow-up edited");
});
expect(onSend).toHaveBeenCalledTimes(2);
});
it("queues image guidance while running and restores it for editing", async () => {
mockBlobUrls();
const onSend = vi.fn();
const { container, rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).toBeTruthy();
const file = new File(["image"], "draft.png", { type: "image/png" });
fireEvent.change(fileInput!, { target: { files: [file] } });
await screen.findByText("draft.png");
fireEvent.change(input, { target: { value: "look at this" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByRole("group", { name: "Queued guidance" })).toBeInTheDocument();
expect(screen.getByText("look at this")).toBeInTheDocument();
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
expect(input).toHaveValue("look at this");
expect(screen.getByTestId("composer-chip")).toHaveTextContent("draft.png");
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith(
"look at this",
[expect.objectContaining({
media: expect.objectContaining({
data_url: "data:image/png;base64,aW1hZ2U=",
name: "draft.png",
}),
})],
);
});
});
it("reorders queued guidance while dragging over another row", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const handles = screen.getAllByLabelText("Drag to reorder");
const secondRow = screen
.getByText("second follow-up")
.closest("[data-queued-prompt-row='true']");
expect(secondRow).toBeTruthy();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
getData: vi.fn(),
};
fireEvent.dragStart(handles[0], { dataTransfer });
fireEvent.dragEnter(secondRow!, { dataTransfer });
fireEvent.dragEnd(handles[0], { dataTransfer });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
});
it("moves later queued guidance before an earlier item while dragging", async () => {
const onSend = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "first follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "second follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
const handles = screen.getAllByLabelText("Drag to reorder");
const firstRow = screen
.getByText("first follow-up")
.closest("[data-queued-prompt-row='true']");
expect(firstRow).toBeTruthy();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
getData: vi.fn(),
};
fireEvent.dragStart(handles[1], { dataTransfer });
fireEvent.dragEnter(firstRow!, { dataTransfer });
fireEvent.dragEnd(handles[1], { dataTransfer });
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming={false}
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("second follow-up");
});
});
it("persists queued guidance per chat across remounts", async () => {
const onSend = vi.fn();
const { rerender, unmount } = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "remember this follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
fireEvent.change(input, { target: { value: "remember this edited follow-up" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("remember this edited follow-up")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-b"
placeholder="Type your message..."
/>,
);
await waitFor(() => {
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
});
unmount();
const remount = render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
remount.unmount();
render(
<div>
<button type="button">outside</button>
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
imageMode
/>
</div>,
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="chat-a"
placeholder="Type your message..."
/>,
);
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" });
fireEvent.click(aspectButton);
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
});
});
});
+24 -29
View File
@@ -9,7 +9,7 @@ import {
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
it("groups consecutive reasoning and tool rows into one cluster before the answer", () => {
it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -55,7 +55,7 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4");
});
it("starts a new activity cluster when the activity segment changes", () => {
it("keeps file edits as their own activity row inside a turn", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -95,14 +95,11 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2",
]);
expect(units).toHaveLength(3);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
});
it("does not split ordinary tool activity just because segment ids changed", () => {
@@ -146,7 +143,7 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(1);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"r2",
@@ -154,7 +151,7 @@ describe("ThreadMessages", () => {
]);
});
it("only marks the current activity cluster as live while streaming", () => {
it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -197,12 +194,10 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity cluster", () => {
it("folds final answer reasoning into the preceding activity timeline", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -234,21 +229,21 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0]).toMatchObject({ type: "cluster" });
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0]).toMatchObject({ type: "activity" });
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"a1-reasoning",
]);
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[0].type === "activity" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "final answer",
},
});
if (units[1].type === "single") {
if (units[1].type === "message") {
expect(units[1].message).not.toHaveProperty("reasoning");
}
@@ -290,12 +285,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "partial answer",
@@ -340,12 +335,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "Hong Kong is hot today.",
@@ -391,12 +386,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude",
"tool",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "final",
content: "Done. Open index.html to play.",
@@ -404,7 +399,7 @@ describe("ThreadMessages", () => {
});
});
it("passes assistant turn latency to the preceding completed activity cluster", () => {
it("passes assistant turn latency to the preceding completed activity timeline", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -496,7 +491,7 @@ describe("ThreadMessages", () => {
const flags = assistantCopyFlags(units);
const assistantFlags = units
.map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant"
unit.type === "message" && unit.message.role === "assistant"
? [unit.message.id, flags[index]]
: null,
)
+2 -4
View File
@@ -270,7 +270,7 @@ describe("ThreadShell", () => {
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
});
it("only shows image generation controls when the setting is enabled", async () => {
it("keeps image generation controls out of the composer", async () => {
const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
const enabledSettings: SettingsPayload = {
@@ -313,7 +313,7 @@ describe("ThreadShell", () => {
);
});
expect(screen.getByRole("button", { name: "Toggle image generation mode" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
});
it("restores in-memory messages when switching away and back to a session", async () => {
@@ -1092,8 +1092,6 @@ describe("ThreadShell", () => {
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
});
+60
View File
@@ -1240,6 +1240,66 @@ describe("useNanobotStream", () => {
]);
});
it("keeps assistant html media as a file attachment", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-html-media", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-html-media", {
event: "message",
chat_id: "chat-html-media",
text: "file ready",
media_urls: [{ url: "/api/media/sig/html", name: "index.html" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
]);
});
it("infers assistant svg media as an image attachment", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-svg-media", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-svg-media", {
event: "message",
chat_id: "chat-svg-media",
text: "chart ready",
media_urls: [{ url: "/api/media/sig/svg", name: "growth.svg" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "image", url: "/api/media/sig/svg", name: "growth.svg" },
]);
});
it("corrects explicit image media when the name is a non-image file", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-mislabelled-html", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-mislabelled-html", {
event: "message",
chat_id: "chat-mislabelled-html",
text: "file ready",
media_urls: [{ kind: "image", url: "/api/media/sig/html", name: "index.html" }],
});
});
expect(result.current.messages[0].media).toEqual([
{ kind: "file", url: "/api/media/sig/html", name: "index.html" },
]);
});
it("suppresses redundant stream confirmation after assistant media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {