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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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></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></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></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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user