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:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+61
View File
@@ -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",
+143 -35
View File
@@ -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 />);
+19
View File
@@ -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>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);
+16
View File
@@ -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",
+18
View File
@@ -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);
});
+386 -1
View File
@@ -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" },
}),
),
);
});
});
+175 -8
View File
@@ -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 },
+58 -21
View File
@@ -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();
+120 -4
View File
@@ -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(
+28 -1
View File
@@ -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),
}),
);
});