feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
@@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchProviderModels,
|
||||
fetchSessionAutomations,
|
||||
fetchSettingsUsage,
|
||||
fetchSidebarState,
|
||||
fetchSkillDetail,
|
||||
fetchSkills,
|
||||
fetchWebuiThread,
|
||||
fetchWorkspaces,
|
||||
importMcpConfig,
|
||||
@@ -55,6 +60,51 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
||||
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/file-preview?path=%2Ftmp%2Fproject%2Fhook.py%3A12",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when fetching session automations", async () => {
|
||||
await fetchSessionAutomations("tok", "websocket:chat-1");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/automations",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the WebUI skill summary", async () => {
|
||||
await fetchSkills("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes skill names when fetching skill details", async () => {
|
||||
await fetchSkillDetail("tok", "current web");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/current%20web",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
|
||||
@@ -86,6 +136,17 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches token usage through the lightweight settings endpoint", async () => {
|
||||
await fetchSettingsUsage("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/usage",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model configuration creation", async () => {
|
||||
await createModelConfiguration("tok", {
|
||||
label: "Fast writing",
|
||||
|
||||
@@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response {
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function mockFetchRoutes(routes: Record<string, unknown>): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const body = routes[String(input)];
|
||||
return body === undefined
|
||||
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
|
||||
: jsonResponse(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function baseSettingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
@@ -208,6 +220,7 @@ describe("App layout", () => {
|
||||
runStatusHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
@@ -243,6 +256,129 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens Skills from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/github": {
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
requirements: {
|
||||
bins: ["gh"],
|
||||
env: [],
|
||||
missing_bins: ["gh"],
|
||||
missing_env: [],
|
||||
},
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
|
||||
|
||||
fireEvent.click(skillsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(document.title).toBe("Skills · nanobot");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
|
||||
expect(await screen.findByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Raw SKILL.md"));
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fully collapses the native host sidebar and previews it on hover", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Desktop chat",
|
||||
},
|
||||
];
|
||||
vi.mocked(fetchBootstrap).mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
runtime_surface: "native",
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const flowSidebar = screen.getByTestId("host-sidebar-flow");
|
||||
const toggle = screen.getByTestId("host-sidebar-toggle");
|
||||
expect(flowSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(toggle);
|
||||
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
|
||||
expect(flowSidebar).toHaveStyle({ width: "0px" });
|
||||
expect(previewSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
within(previewSidebar).getByRole("navigation", {
|
||||
name: "Sidebar navigation",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(flowSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the next session when deleting the active chat", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -907,7 +1043,6 @@ describe("App layout", () => {
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(document.title).toBe("Settings · nanobot");
|
||||
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
|
||||
@@ -1036,15 +1171,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("restores the settings section from the URL hash after a page reload", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
window.history.replaceState(null, "", "/#/settings?section=models");
|
||||
|
||||
render(<App />);
|
||||
@@ -1055,15 +1182,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("updates the URL hash when switching settings sections", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -1081,22 +1200,11 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
if (href === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
|
||||
}
|
||||
if (href === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -51,6 +51,25 @@ describe("CodeBlock", () => {
|
||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
|
||||
});
|
||||
|
||||
it("can render without chat-style chrome for file previews", () => {
|
||||
render(
|
||||
<ThemeProvider theme="light">
|
||||
<CodeBlock
|
||||
language="html"
|
||||
code="<main />"
|
||||
chrome="none"
|
||||
highlight={false}
|
||||
showLineNumbers
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("html")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("bg-transparent");
|
||||
});
|
||||
|
||||
it("falls back to 'text' language when language is undefined", async () => {
|
||||
render(
|
||||
<ThemeProvider theme="dark">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||||
|
||||
@@ -12,6 +12,67 @@ describe("MarkdownTextRenderer", () => {
|
||||
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
|
||||
});
|
||||
|
||||
it("renders local file links as previewable file references", () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
render(
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Edited [hook.py](/Users/test/project/nanobot/agent/hook.py:12)"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
expect(reference).toHaveTextContent("hook.py");
|
||||
expect(reference).toHaveAttribute(
|
||||
"aria-label",
|
||||
"/Users/test/project/nanobot/agent/hook.py",
|
||||
);
|
||||
|
||||
fireEvent.click(reference);
|
||||
|
||||
expect(onOpenFilePreview).toHaveBeenCalledWith(
|
||||
"/Users/test/project/nanobot/agent/hook.py",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
render(
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Download [index.html](/api/media/sig/html)"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "index.html" })).toHaveAttribute(
|
||||
"href",
|
||||
"/api/media/sig/html",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders glob file links as plain text instead of preview targets", () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"原始对话通常还在 [*.json](*.json)。"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "*.json" })).not.toBeInTheDocument();
|
||||
expect(container).toHaveTextContent("*.json");
|
||||
});
|
||||
|
||||
it("keeps glob inline code as code instead of a file preview chip", () => {
|
||||
render(
|
||||
<MarkdownTextRenderer>
|
||||
{"检查 `src/**/*.json`。"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("src/**/*.json").tagName).toBe("CODE");
|
||||
});
|
||||
|
||||
it("does not wrap complete fenced code blocks in an extra pre", () => {
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer highlightCode={false}>
|
||||
@@ -117,6 +178,42 @@ describe("MarkdownTextRenderer", () => {
|
||||
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
|
||||
});
|
||||
|
||||
it("falls back through favicon sources before showing a globe for compact link rows", () => {
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer>
|
||||
{
|
||||
"Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx"
|
||||
}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
const link = screen.getByRole("link", {
|
||||
name: "Open link: Savills Hong Kong Corporate Relocation — Corporate relocation services",
|
||||
});
|
||||
const favicon = () => link.querySelector("img");
|
||||
|
||||
expect(favicon()).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.savills.com.hk/favicon.ico",
|
||||
);
|
||||
|
||||
fireEvent.error(favicon()!);
|
||||
expect(favicon()).toHaveAttribute(
|
||||
"src",
|
||||
"https://icons.duckduckgo.com/ip3/www.savills.com.hk.ico",
|
||||
);
|
||||
|
||||
fireEvent.error(favicon()!);
|
||||
expect(favicon()).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64",
|
||||
);
|
||||
|
||||
fireEvent.error(favicon()!);
|
||||
expect(favicon()).not.toBeInTheDocument();
|
||||
expect(link.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container).not.toHaveTextContent("SC");
|
||||
});
|
||||
|
||||
it("renders media attachments without an extra preview/code wrapper", () => {
|
||||
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("MessageBubble", () => {
|
||||
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a lightweight automation source label for cron replies", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-cron",
|
||||
role: "assistant",
|
||||
content: "Time to drink water.",
|
||||
source: { kind: "cron", label: "drink water" },
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.getByText("drink water")).toBeInTheDocument();
|
||||
expect(screen.getByText("Triggered automatically")).toBeInTheDocument();
|
||||
expect(screen.getByText("Time to drink water.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders structured CLI app attachments even without the installed catalog", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli-attached",
|
||||
|
||||
@@ -429,6 +429,24 @@ describe("NanobotClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes an explicit turn id on outbound WebUI messages", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-x", "hello", undefined, { turnId: "turn-1" });
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "message",
|
||||
chat_id: "chat-x",
|
||||
content: "hello",
|
||||
turn_id: "turn-1",
|
||||
webui: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes image generation options in outbound messages", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
|
||||
function automationJob(nextRunAt = Date.now() + 3_600_000) {
|
||||
return {
|
||||
id: "job-1",
|
||||
name: "Morning check",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", every_ms: 3_600_000 },
|
||||
payload: { message: "Check the project status" },
|
||||
state: { next_run_at_ms: nextRunAt },
|
||||
};
|
||||
}
|
||||
|
||||
function automationsResponse(jobs: unknown[]) {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({
|
||||
jobs,
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("SessionInfoPopover", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads and displays session automations when opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SessionInfoPopover
|
||||
sessionKey="websocket:chat-1"
|
||||
token="tok"
|
||||
title="Release work"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/automations",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Morning check")).toBeInTheDocument();
|
||||
expect(screen.getByText("Check the project status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("localizes the panel chrome in Simplified Chinese", async () => {
|
||||
await setAppLanguage("zh-CN");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SessionInfoPopover
|
||||
sessionKey="websocket:chat-1"
|
||||
token="tok"
|
||||
title="@hyperframes 使用指南"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "会话详情" }));
|
||||
|
||||
expect(await screen.findByText("会话")).toBeInTheDocument();
|
||||
expect(screen.getByText("自动任务")).toBeInTheDocument();
|
||||
expect(screen.getByText("Morning check")).toBeInTheDocument();
|
||||
expect(screen.getByText(/下次/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes while open so completed one-shot automations disappear", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(automationsResponse([automationJob(Date.now() + 1000)]))
|
||||
.mockResolvedValue(automationsResponse([])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SessionInfoPopover
|
||||
sessionKey="websocket:chat-1"
|
||||
token="tok"
|
||||
title="Release work"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
expect(await screen.findByText("Morning check")).toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText("Morning check")).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 4500 },
|
||||
);
|
||||
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
||||
}, 8000);
|
||||
});
|
||||
@@ -118,8 +118,9 @@ const installedAnyGen = {
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?: "apps" | "advanced" | "models";
|
||||
initialSection?: "overview" | "apps" | "advanced" | "models";
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
onNativeEngineRestart?: () => Promise<string>;
|
||||
} = {},
|
||||
) {
|
||||
render(
|
||||
@@ -131,6 +132,7 @@ function renderSettingsView(
|
||||
onBackToChat={() => {}}
|
||||
onModelNameChange={() => {}}
|
||||
onSettingsChange={options.onSettingsChange}
|
||||
onNativeEngineRestart={options.onNativeEngineRestart}
|
||||
/>
|
||||
</ClientProvider>,
|
||||
);
|
||||
@@ -219,6 +221,55 @@ describe("SettingsView Apps catalog", () => {
|
||||
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
|
||||
});
|
||||
|
||||
it("shows token activity on the overview", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
usage: {
|
||||
days: [
|
||||
{
|
||||
date: "2026-06-03",
|
||||
prompt_tokens: 1200,
|
||||
completion_tokens: 300,
|
||||
cached_tokens: 500,
|
||||
total_tokens: 1500,
|
||||
requests: 2,
|
||||
},
|
||||
],
|
||||
total_tokens: 1500,
|
||||
total_tokens_30d: 1500,
|
||||
total_tokens_365d: 1500,
|
||||
peak_day_tokens: 1500,
|
||||
current_streak_days: 1,
|
||||
longest_streak_days: 1,
|
||||
active_days_30d: 1,
|
||||
requests_30d: 2,
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "overview" });
|
||||
|
||||
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Token Usage")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows context window options in model settings", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -242,6 +293,280 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the current model as unconfigured when its provider needs setup", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "openai-codex/gpt-5.1-codex",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
has_api_key: false,
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "openai-codex/gpt-5.1-codex",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const configurationButton = await screen.findByRole("button", {
|
||||
name: "Current configuration",
|
||||
});
|
||||
expect(configurationButton).toHaveTextContent("Not configured");
|
||||
expect(configurationButton).toHaveTextContent("OpenAI Codex · openai-codex/gpt-5.1-codex");
|
||||
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps unsigned OAuth providers out of the active provider picker", 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",
|
||||
},
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
{
|
||||
name: "github_copilot",
|
||||
label: "GitHub Copilot",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://api.githubcopilot.com",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
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 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
|
||||
const providerPicker = deepseekButtons.find(
|
||||
(button) => button.getAttribute("aria-haspopup") === "menu",
|
||||
);
|
||||
if (!providerPicker) throw new Error("provider picker was not found");
|
||||
fireEvent.pointerDown(providerPicker);
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch model lists for unsigned OAuth providers", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
{
|
||||
name: "github_copilot",
|
||||
label: "GitHub Copilot",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://api.githubcopilot.com",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
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 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
|
||||
expect(
|
||||
await screen.findByText("Configure this provider before loading models."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).startsWith("/api/settings/provider-models"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefills manual model ids for configured OAuth providers", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "open-codex/gpt-5.5",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "open-codex/gpt-5.5",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: true,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: "acct-test",
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
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 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
|
||||
expect(input.value).toBe("open-codex/gpt-5.5");
|
||||
|
||||
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
|
||||
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).startsWith("/api/settings/provider-models"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("can close the new configuration dialog without trapping the settings page", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -443,4 +768,64 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes settings with a fresh token after native engine restart", async () => {
|
||||
const payload = {
|
||||
...settingsPayload(),
|
||||
surface: "native" as const,
|
||||
runtime_surface: "native" as const,
|
||||
runtime_capabilities: {
|
||||
can_restart_engine: true,
|
||||
can_pick_folder: true,
|
||||
can_open_logs: true,
|
||||
can_export_diagnostics: true,
|
||||
},
|
||||
};
|
||||
const restartedPayload = {
|
||||
...payload,
|
||||
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
|
||||
requires_restart: true,
|
||||
restart_required_sections: ["runtime"],
|
||||
};
|
||||
const refreshedPayload = {
|
||||
...restartedPayload,
|
||||
requires_restart: false,
|
||||
restart_required_sections: [],
|
||||
};
|
||||
const restartEngine = vi.fn(async () => "fresh-token");
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
|
||||
if (url === "/api/settings" && auth === "Bearer fresh-token") {
|
||||
return jsonResponse(refreshedPayload);
|
||||
}
|
||||
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/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
|
||||
return jsonResponse(restartedPayload);
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({
|
||||
initialSection: "advanced",
|
||||
onNativeEngineRestart: restartEngine,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("App safety")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer fresh-token" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("ThreadMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a later tool segment after the visible answer that preceded it", () => {
|
||||
it("moves orphan trailing activity before the completed assistant answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "Let me search the latest data.",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("only marks the current activity timeline as live while streaming", () => {
|
||||
@@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
|
||||
@@ -344,7 +344,7 @@ describe("ThreadMessages", () => {
|
||||
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps late activity after a completed assistant answer", () => {
|
||||
it("moves late activity before a completed assistant answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "Hong Kong is hot today.",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const answer = screen.getByText("Hong Kong is hot today.");
|
||||
const laterActivity = screen.getAllByText(/thought/i).at(-1);
|
||||
expect(laterActivity).toBeTruthy();
|
||||
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not leave a completed web-search thought below the final answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "user",
|
||||
role: "user",
|
||||
content: "最近科隆major开打了,你知道不?",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "thought",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I should verify the current event details.",
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "知道,IEM Cologne Major 2026 今天开打了。",
|
||||
latencyMs: 18_000,
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026",
|
||||
traces: ["Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026"],
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const thought = screen.getAllByText(/thought/i).at(-1);
|
||||
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
|
||||
expect(thought).toBeTruthy();
|
||||
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("normalizes completed prior turns while the next user turn is streaming", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "thought",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I should verify the current event details.",
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
|
||||
latencyMs: 20_000,
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: site:counter-strike.net majors 2026",
|
||||
traces: ["Searching query: site:counter-strike.net majors 2026"],
|
||||
activitySegmentId: "seg-major",
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "next-user",
|
||||
role: "user",
|
||||
content: "看一下目前的赛果,整个表哥",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(4);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"thought",
|
||||
]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"web",
|
||||
]);
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
expect(units[3]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "next-user" },
|
||||
});
|
||||
});
|
||||
|
||||
it("orders live turn activity by causal turn sequence before the final answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "web-1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: 2026 Counter-Strike 2 Major location",
|
||||
traces: ["Searching query: 2026 Counter-Strike 2 Major location"],
|
||||
turnId: "turn-major",
|
||||
turnSeq: 3,
|
||||
activitySegmentId: "seg-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "answer",
|
||||
role: "assistant",
|
||||
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
|
||||
isStreaming: true,
|
||||
turnId: "turn-major",
|
||||
turnSeq: 84,
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
id: "web-2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "Searching query: site:counter-strike.net majors 2026",
|
||||
traces: ["Searching query: site:counter-strike.net majors 2026"],
|
||||
turnId: "turn-major",
|
||||
turnSeq: 83,
|
||||
activitySegmentId: "seg-2",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"web-1",
|
||||
"web-2",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders interrupted pre-tool text as activity before the final answer", () => {
|
||||
@@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses turn ids as activity grouping boundaries when available", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "answer one", turnId: "turn-1", createdAt: 2 },
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
turnId: "turn-2",
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "answer two", turnId: "turn-2", createdAt: 4 },
|
||||
]);
|
||||
|
||||
expect(units.map((unit) => unit.type === "message" ? unit.message.id : "activity")).toEqual([
|
||||
"u1",
|
||||
"a1",
|
||||
"activity",
|
||||
"a2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
|
||||
@@ -78,6 +78,20 @@ function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelN
|
||||
);
|
||||
}
|
||||
|
||||
function expectSendMessageWithTurn(
|
||||
client: ReturnType<typeof makeClient>,
|
||||
chatId: string,
|
||||
content: string,
|
||||
options: unknown = undefined,
|
||||
) {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
chatId,
|
||||
content,
|
||||
options,
|
||||
expect.objectContaining({ turnId: expect.any(String) }),
|
||||
);
|
||||
}
|
||||
|
||||
function session(chatId: string) {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
@@ -270,6 +284,45 @@ describe("ThreadShell", () => {
|
||||
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||
settings.agent.has_api_key = false;
|
||||
settings.providers = settings.providers.map((provider) =>
|
||||
provider.name === "openai_codex"
|
||||
? { ...provider, auth_type: "oauth", configured: false }
|
||||
: provider,
|
||||
);
|
||||
const onOpenModelSettings = vi.fn();
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("unconfigured-model")}
|
||||
title="Unconfigured model"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
/>,
|
||||
"openai-codex/gpt-5.1-codex",
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByRole("button", { name: "Model not configured" });
|
||||
expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
|
||||
fireEvent.click(badge);
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps image generation controls out of the composer", async () => {
|
||||
const client = makeClient();
|
||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
@@ -339,11 +392,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"persist me across tabs",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "persist me across tabs"),
|
||||
);
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
|
||||
@@ -403,11 +452,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"delete me cleanly",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "delete me cleanly"),
|
||||
);
|
||||
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
||||
|
||||
@@ -506,11 +551,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-new",
|
||||
"first message should stay",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-new", "first message should stay"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
|
||||
@@ -575,7 +616,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
|
||||
expectSendMessageWithTurn(client, "chat-new", "/model"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -703,11 +744,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"only in chat a",
|
||||
undefined,
|
||||
),
|
||||
expectSendMessageWithTurn(client, "chat-a", "only in chat a"),
|
||||
);
|
||||
expect(screen.getByText("only in chat a")).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import {
|
||||
HISTORY_WINDOW_INCREMENT,
|
||||
INITIAL_HISTORY_WINDOW,
|
||||
ThreadViewport,
|
||||
type ThreadViewportHandle,
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
@@ -35,6 +38,24 @@ function makeLongMessages(count: number): UIMessage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<PromptNavigator
|
||||
messages={messages}
|
||||
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
|
||||
/>
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
@@ -75,7 +96,9 @@ describe("ThreadViewport", () => {
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||
expect(button).toHaveStyle({ bottom: "192px" });
|
||||
const buttonPositioner = button.parentElement as HTMLElement;
|
||||
expect(button).not.toHaveClass("-translate-x-1/2");
|
||||
expect(buttonPositioner).toHaveStyle({ bottom: "192px" });
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
@@ -100,7 +123,7 @@ describe("ThreadViewport", () => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(button).toHaveStyle({ bottom: "256px" });
|
||||
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
}
|
||||
@@ -207,7 +230,10 @@ describe("ThreadViewport", () => {
|
||||
|
||||
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jump to prompt: message 3" }));
|
||||
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
|
||||
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(targetPrompt);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1064,
|
||||
@@ -215,6 +241,96 @@ describe("ThreadViewport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
|
||||
const promptMessages = makeLongMessages(5);
|
||||
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
|
||||
|
||||
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1800 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
const promptEls = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||
);
|
||||
promptEls.forEach((el, index) => {
|
||||
Object.defineProperty(el, "offsetTop", {
|
||||
configurable: true,
|
||||
value: index * 360,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
|
||||
target: { value: "message 4" },
|
||||
});
|
||||
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1424,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands the history window before jumping to an older prompt from the navigator", async () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
render(<ViewportWithPromptNavigator messages={longMessages} />);
|
||||
|
||||
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders the prompt rail for compact scroll ranges", async () => {
|
||||
const promptMessages = makeLongMessages(3);
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={promptMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 700 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
const promptEls = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||
);
|
||||
expect(promptEls).toHaveLength(3);
|
||||
promptEls.forEach((el, index) => {
|
||||
Object.defineProperty(el, "offsetTop", {
|
||||
configurable: true,
|
||||
value: index * 50,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
|
||||
const promptMessages = makeLongMessages(100);
|
||||
const { container } = render(
|
||||
|
||||
@@ -157,6 +157,28 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves proactive automation source metadata on complete assistant messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-cron", {
|
||||
event: "message",
|
||||
chat_id: "chat-cron",
|
||||
text: "Time to drink water.",
|
||||
source: { kind: "cron", label: "drink water" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Time to drink water.",
|
||||
source: { kind: "cron", label: "drink water" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -1342,6 +1364,8 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("user");
|
||||
expect(result.current.messages[0].content).toBe("fine");
|
||||
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
|
||||
expect(result.current.messages[0].turnPhase).toBe("user");
|
||||
});
|
||||
|
||||
it("attaches assistant media_urls to complete messages", () => {
|
||||
@@ -1482,7 +1506,10 @@ describe("useNanobotStream", () => {
|
||||
"chat-img",
|
||||
"draw a square icon",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
|
||||
expect.objectContaining({
|
||||
imageGeneration: { enabled: true, aspect_ratio: "1:1" },
|
||||
turnId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user