feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
@@ -433,6 +433,72 @@ describe("AgentActivityCluster", () => {
}
});
it("labels whole-file deletes as deleted instead of edited", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t-delete",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-delete",
tool: "apply_patch",
path: "angry-birds.html",
phase: "end",
added: 0,
deleted: 590,
approximate: false,
status: "done",
operation: "delete",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /deleted angry-birds\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edited angry-birds\.html/i })).not.toBeInTheDocument();
});
it("renders file-only edits without a redundant disclosure", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-file-only",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.queryByRole("button", { name: /edited app\.tsx/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByText("Edited")).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.getByText("-3")).toBeInTheDocument();
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
@@ -771,6 +837,38 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("shows the reason when a file edit fails", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [{
call_id: "call-patch",
tool: "apply_patch",
path: "angry-birds.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "Error applying patch: old_text not found in angry-birds.html",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /failed angry-birds\.html/i }));
expect(screen.getByText("Target text was not found in angry-birds.html.")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
+106
View File
@@ -7,15 +7,20 @@ import {
fetchMcpPresets,
fetchSidebarState,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
listSessions,
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
updateMcpServerTools,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -89,6 +94,44 @@ describe("webui API helpers", () => {
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reports HTML API fallbacks as gateway mismatch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
text: async () => "<!doctype html><html></html>",
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
provider: "openrouter",
@@ -104,6 +147,24 @@ describe("webui API helpers", () => {
);
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
@@ -121,6 +182,20 @@ describe("webui API helpers", () => {
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
enabled: true,
@@ -257,6 +332,7 @@ describe("webui API helpers", () => {
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
collapsed_groups: {},
view: {
@@ -292,9 +368,39 @@ describe("webui API helpers", () => {
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
});
it("fetches workspace project state", async () => {
const payload = {
schema_version: 1,
default_access_mode: "default" as const,
default_scope: {
project_path: "/tmp/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
},
controls: {
can_change_project: true,
can_use_full_access: true,
},
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => payload,
} as Response);
await expect(fetchWorkspaces("tok")).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith(
"/api/workspaces",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+25 -32
View File
@@ -12,6 +12,8 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function jsonResponse(body: unknown): Response {
return {
@@ -97,6 +99,9 @@ function baseSettingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -412,29 +417,7 @@ describe("App layout", () => {
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
it("sorts chats by displayed title when A-Z is persisted", async () => {
@@ -785,6 +768,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -828,8 +814,8 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
@@ -906,9 +892,13 @@ describe("App layout", () => {
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
@@ -1071,6 +1061,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -1097,7 +1090,7 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
});
it("filters sessions in the centered search dialog", async () => {
@@ -1266,23 +1259,23 @@ describe("App layout", () => {
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("56px"));
const sidebarAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(sidebarAside.style.width).toBe("56px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(rail).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
await waitFor(() => expect(sidebarAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { deriveWsUrl } from "@/lib/bootstrap";
describe("bootstrap helpers", () => {
it("prefers the server-provided websocket URL over the current dev host", () => {
expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe(
"ws://127.0.0.1:8765/?token=tok%20en",
);
});
it("preserves the host socket bridge URL", () => {
expect(deriveWsUrl("/", "tok en", "nanobot-host://engine/")).toBe(
"nanobot-host://engine/?token=tok%20en",
);
});
it("falls back to the current window host for legacy bootstrap payloads", () => {
expect(deriveWsUrl("/", "tok")).toBe(
"ws://localhost:3000/?token=tok",
);
});
});
+257
View File
@@ -0,0 +1,257 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary {
const chatId = overrides.chatId ?? "chat";
return {
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: "2026-05-20T10:00:00Z",
updatedAt: "2026-05-20T10:00:00Z",
preview: "",
...overrides,
};
}
describe("ChatList", () => {
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
const sessions = [
session({
chatId: "zeta",
title: "Zeta task",
updatedAt: "2026-05-20T12:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
session({
chatId: "alpha",
title: "Alpha task",
updatedAt: "2026-05-20T11:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
session({
chatId: "bench",
title: "Bench task",
updatedAt: "2026-05-21T09:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot-bench",
project_name: "nanobot-bench",
access_mode: "full",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:alpha"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
sort="title_asc"
showTimestamps
runningChatIds={["zeta"]}
/>,
);
const nanobotSection = screen.getByRole("region", { name: "nanobot" });
const nanobotText = nanobotSection.textContent ?? "";
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument();
expect(screen.queryByText("Today")).not.toBeInTheDocument();
});
it("keeps default workspace chats in the Chats section instead of a project folder", () => {
const sessions = [
session({
chatId: "default",
title: "Default workspace chat",
updatedAt: "2026-05-21T10:00:00Z",
workspaceScope: {
project_path: "/Users/me/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted",
},
}),
session({
chatId: "project",
title: "Project chat",
updatedAt: "2026-05-21T11:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:default"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
defaultWorkspacePath="/Users/me/.nanobot/workspace"
showTimestamps
/>,
);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "nanobot" })).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "workspace" })).not.toBeInTheDocument();
const chatsSection = screen.getByRole("region", { name: "Chats" });
expect(within(chatsSection).getByText("Default workspace chat")).toBeInTheDocument();
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
const onToggleGroup = vi.fn();
const onRequestRenameProject = vi.fn();
const onNewChatInProject = vi.fn();
const sessions = [
session({
chatId: "alpha",
title: "Alpha task",
workspaceScope: {
project_path: "/Users/me/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:alpha"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onToggleGroup={onToggleGroup}
onRequestRenameProject={onRequestRenameProject}
onNewChatInProject={onNewChatInProject}
projectNameOverrides={{ "/Users/me/nanobot": "Photos" }}
collapsedGroups={{ "project:/Users/me/nanobot": true }}
/>,
);
const projectSection = screen.getByRole("region", { name: "Photos" });
fireEvent.click(within(projectSection).getByRole("button", { name: "Photos" }));
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
fireEvent.click(
within(projectSection).getByRole("button", { name: "Start a new chat in Photos" }),
);
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(
within(projectSection).getByLabelText("Chat actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("hides the completed dot for the active chat", () => {
const sessions = [
session({
chatId: "active",
title: "Active task",
}),
session({
chatId: "done",
title: "Done task",
}),
];
render(
<ChatList
sessions={sessions}
activeKey="websocket:active"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
completedChatIds={["active", "done"]}
/>,
);
expect(screen.getAllByLabelText("Agent finished")).toHaveLength(1);
});
it("folds long default workspace chats and can show all", () => {
const sessions = Array.from({ length: 10 }, (_, index) =>
session({
chatId: `chat-${index}`,
title: `Chat ${index}`,
updatedAt: `2026-05-21T10:${String(index).padStart(2, "0")}:00Z`,
workspaceScope: {
project_path: "/Users/me/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted",
},
}),
);
const onToggleGroup = vi.fn();
const baseProps = {
sessions,
activeKey: null,
onSelect: vi.fn(),
onRequestDelete: vi.fn(),
onTogglePin: vi.fn(),
onRequestRename: vi.fn(),
onToggleArchive: vi.fn(),
onToggleGroup,
defaultWorkspacePath: "/Users/me/.nanobot/workspace",
};
const { rerender } = render(<ChatList {...baseProps} />);
const chatsSection = screen.getByRole("region", { name: "Chats" });
expect(within(chatsSection).getByText("Chat 9")).toBeInTheDocument();
expect(within(chatsSection).getByText("Chat 2")).toBeInTheDocument();
expect(within(chatsSection).queryByText("Chat 1")).not.toBeInTheDocument();
expect(within(chatsSection).queryByRole("button", { name: "Show all" })).not.toBeInTheDocument();
fireEvent.click(within(chatsSection).getByRole("button", { name: "2 hidden chats" }));
expect(onToggleGroup).toHaveBeenCalledWith("workspace:chats");
rerender(
<ChatList
{...baseProps}
collapsedGroups={{ "workspace:chats": false }}
/>,
);
expect(within(chatsSection).getByText("Chat 0")).toBeInTheDocument();
expect(within(chatsSection).getByRole("button", { name: "Show less" })).toBeInTheDocument();
});
});
+16 -5
View File
@@ -5,9 +5,11 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
import { LOCALE_STORAGE_KEY, resolveInitialLocale } from "@/i18n/config";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
const HERO_GREETING_KEYS = ["workOn", "start", "build", "tackle"];
const SLASH_COMMAND_KEYS = [
"new",
"stop",
@@ -27,12 +29,11 @@ const SETTINGS_NAV_KEYS = [
"appearance",
"models",
"image",
"web",
"browser",
"apps",
"runtime",
"advanced",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -61,6 +62,14 @@ function interpolationKeys(value: unknown): string[] {
}
describe("webui i18n", () => {
it("defaults to English until the user chooses another language", () => {
localStorage.removeItem(LOCALE_STORAGE_KEY);
expect(resolveInitialLocale()).toBe("en");
localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
expect(resolveInitialLocale()).toBe("zh-CN");
});
it("switches UI copy and document locale through the language switcher", async () => {
const user = userEvent.setup();
@@ -97,10 +106,12 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
});
it("keeps welcome quick actions localized for every registered locale", () => {
it("keeps empty landing resources localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of HERO_GREETING_KEYS) {
expect(empty.greetings[key as keyof typeof empty.greetings]).toBeTruthy();
}
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
@@ -182,7 +193,7 @@ describe("webui i18n", () => {
it("keeps Simplified Chinese settings overview copy localized", () => {
const settings = resources["zh-CN"].common.settings;
expect(settings.nav.web).toBe("网页");
expect(settings.nav.browser).toBe("网页");
expect(settings.sections.webSearch).toBe("网页搜索");
expect(settings.byok.tabs.webSearch).toBe("网页搜索");
expect(settings.overview.webSearch).toBe("网页搜索");
+115 -1
View File
@@ -132,6 +132,30 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-strip",
});
expect(client.getRunStartedAt("chat-strip")).toBeNull();
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
});
it("notifies run status subscribers and replays running chats", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -268,9 +292,19 @@ describe("NanobotClient", () => {
event: "session_updated",
chat_id: "chat-title",
scope: "metadata",
workspace_scope: {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
restrict_to_workspace: true,
},
});
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
expect(globalHandler).toHaveBeenCalledWith(
"chat-title",
"metadata",
expect.objectContaining({ project_path: "/tmp/project" }),
);
expect(chatHandler).not.toHaveBeenCalled();
});
@@ -288,6 +322,40 @@ describe("NanobotClient", () => {
await expect(promise).resolves.toBe("fresh-id");
});
it("serializes workspace scope for new chats and messages", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const workspaceScope = {
project_path: "/tmp/project",
project_name: "project",
access_mode: "full" as const,
restrict_to_workspace: false,
};
client.connect();
lastSocket().fakeOpen();
const promise = client.newChat(1_000, workspaceScope);
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "new_chat", workspace_scope: workspaceScope }),
);
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
await expect(promise).resolves.toBe("fresh-id");
client.sendMessage("fresh-id", "hello", undefined, { workspaceScope });
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "fresh-id",
content: "hello",
workspace_scope: workspaceScope,
webui: true,
}),
);
});
it("queues sends while connecting and flushes on open", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -536,6 +604,52 @@ describe("NanobotClient", () => {
expect(errors).toEqual([{ kind: "message_too_big" }]);
});
it("emits workspace scope rejection errors from server frames", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; reason?: string; chatId?: string }> = [];
client.onError((e) => errors.push(e));
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-a",
detail: "workspace_scope_rejected",
reason: "chat_running",
});
expect(errors).toEqual([
{
kind: "workspace_scope_rejected",
reason: "chat_running",
chatId: "chat-a",
},
]);
});
it("rejects pending new chats when workspace scope is rejected", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const pending = client.newChat(5_000, {
project_path: "/missing",
project_name: "missing",
access_mode: "restricted",
});
lastSocket().fakeMessage({
event: "error",
detail: "workspace_scope_rejected",
reason: "project_path must be an existing directory",
});
await expect(pending).rejects.toThrow("workspace_scope_rejected");
});
it("isolates throwing error handlers so reconnect bookkeeping still runs", async () => {
const client = new NanobotClient({
url: "ws://test",
+6
View File
@@ -34,4 +34,10 @@ describe("provider brand logos", () => {
expect(providerBrand("zhipu")?.logoUrls).toContain("https://z.ai/favicon.ico");
expect(providerBrand("zhipu")?.initials).toBe("Z");
});
it("uses official first-party assets for LongCat and Xiaomi MIMO", () => {
expect(providerBrand("longcat")?.logoUrls[0]).toBe("https://www.longcatai.org/favicon.svg");
expect(providerBrand("xiaomi_mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
expect(providerBrand("mimo")?.logoUrls[0]).toBe("https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg");
});
});
+106 -3
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { SettingsPayload } from "@/lib/types";
function jsonResponse(body: unknown): Response {
return {
@@ -12,7 +13,7 @@ function jsonResponse(body: unknown): Response {
} as Response;
}
function settingsPayload() {
function settingsPayload(): SettingsPayload {
return {
agent: {
model: "openai/gpt-4o",
@@ -88,6 +89,9 @@ function settingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -115,15 +119,21 @@ const installedAnyGen = {
skill_installed: true,
};
function renderSettingsView() {
function renderSettingsView(
options: {
initialSection?: "apps" | "advanced";
onSettingsChange?: (payload: SettingsPayload) => void;
} = {},
) {
render(
<ClientProvider client={{} as never} token="tok">
<SettingsView
theme="light"
initialSection="apps"
initialSection={options.initialSection ?? "apps"}
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
/>
</ClientProvider>,
);
@@ -188,4 +198,97 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();
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({ onSettingsChange });
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse({
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "advanced" });
expect(await screen.findByText("Web safety")).toBeInTheDocument();
expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();
expect(screen.queryByText("Private Service Protection")).not.toBeInTheDocument();
expect(screen.getByText("Default access")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restricted" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Default Permission" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Full Access" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("uses native host safety copy on the native surface", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
};
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: "advanced" });
expect(await screen.findByText("App safety")).toBeInTheDocument();
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
});
+247 -7
View File
@@ -98,7 +98,7 @@ const MCP_PRESETS: McpPresetInfo[] = [
description: "Design context",
docs_url: "https://figma.com",
transport: "streamableHttp",
requires: "Figma desktop",
requires: "Figma local app",
note: "",
install_supported: true,
installed: true,
@@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, "nanobotHost");
window.localStorage.clear();
Object.defineProperty(window, "innerHeight", {
value: ORIGINAL_INNER_HEIGHT,
@@ -182,6 +183,167 @@ describe("ThreadComposer", () => {
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("renders and changes workspace access mode", async () => {
const onWorkspaceScopeChange = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
workspaceScope={{
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
restrict_to_workspace: true,
}}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Workspace access mode" }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
expect.objectContaining({
project_path: "/tmp/project",
access_mode: "full",
restrict_to_workspace: false,
}),
);
});
it("keeps project selection as a compact composer dropdown", async () => {
const onWorkspaceScopeChange = vi.fn();
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={{
...defaultScope,
access_mode: "full",
restrict_to_workspace: false,
}}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const input = screen.getByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter an absolute folder path on this machine.",
);
expect(onWorkspaceScopeChange).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "/Users/test/project-alpha" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/project-alpha",
project_name: "project-alpha",
access_mode: "full",
restrict_to_workspace: false,
}));
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
const reopenedInput = await screen.findByLabelText("Paste path");
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(onWorkspaceScopeChange).toHaveBeenLastCalledWith(expect.objectContaining({
project_path: "~/Pictures/Photos",
project_name: "Photos",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the native folder picker for project selection on native host", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
Object.defineProperty(window, "nanobotHost", {
configurable: true,
value: {
getRuntimeInfo: vi.fn(),
restartEngine: vi.fn(),
pickFolder,
openLogs: vi.fn(),
exportDiagnostics: vi.fn(),
},
});
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/native-project",
project_name: "native-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the web path menu when no native host picker is available", async () => {
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={vi.fn()}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
});
it("shows turn run timer when runStartedAt is set", () => {
@@ -242,12 +404,7 @@ describe("ThreadComposer", () => {
const palette = screen.getByRole("listbox", { name: "Slash commands" });
expect(palette).toBeInTheDocument();
expect(palette).toHaveStyle({ maxHeight: "288px" });
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.queryByRole("option", { name: /\/stop/i })).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
@@ -310,6 +467,7 @@ describe("ThreadComposer", () => {
expect(onStop).toHaveBeenCalledTimes(1);
expect(input).toHaveValue("");
expect(window.localStorage.getItem("nanobot.webui.slashCommandRecents")).toBeNull();
});
it("orders recent slash commands first for the blank slash menu", () => {
@@ -333,6 +491,42 @@ describe("ThreadComposer", () => {
expect(screen.getByText("Recent")).toBeInTheDocument();
});
it("keeps keyboard-selected slash options visible while navigating", () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
slashCommands={Array.from({ length: 8 }, (_, index) => ({
command: `/cmd-${index}`,
title: `Command ${index}`,
description: `Description ${index}`,
icon: "activity",
}))}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
scrollIntoView.mockClear();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/cmd-2/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
it("opens the CLI app mention palette and inserts the selected app", () => {
const onSend = vi.fn();
render(
@@ -381,6 +575,52 @@ describe("ThreadComposer", () => {
});
});
it("keeps keyboard-selected mention options visible while navigating", () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={Array.from({ length: 8 }, (_, index) => ({
name: `app-${index}`,
display_name: `App ${index}`,
category: "test",
description: "Test app",
requires: "",
source: "harness",
entry_point: `app-${index}`,
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: null,
brand_color: "#111827",
skill_installed: true,
}))}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
scrollIntoView.mockClear();
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /@app-2/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
render(
<ThreadComposer
+146
View File
@@ -258,6 +258,152 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument();
});
it("keeps late activity above the live assistant answer while streaming", () => {
const messages: UIMessage[] = [
{
id: "t0",
role: "tool",
kind: "trace",
content: "Thinking",
traces: ["Thinking"],
activitySegmentId: "seg-live",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "partial answer",
isStreaming: true,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading api.github.com/repos/NousResearch/hermes-agent",
traces: ["Reading api.github.com/repos/NousResearch/hermes-agent"],
activitySegmentId: "seg-live",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "partial answer",
},
});
render(<ThreadMessages messages={messages} isStreaming />);
const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity above a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "checking weather",
activitySegmentId: "seg-late",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "Hong Kong is hot today.",
latencyMs: 161_000,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading hko.gov.hk/en/wxinfo/currwx/current.htm",
traces: ["Reading hko.gov.hk/en/wxinfo/currwx/current.htm"],
activitySegmentId: "seg-late",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "Hong Kong is hot today.",
},
});
render(<ThreadMessages messages={messages} isStreaming={false} />);
const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today.");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getAllByText(/thought/i)).toHaveLength(1);
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
const messages: UIMessage[] = [
{
id: "prelude",
role: "assistant",
content: "",
reasoning: "I will inspect first.",
isStreaming: false,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "tool",
role: "tool",
kind: "trace",
content: 'exec({"cmd":"ls"})',
traces: ['exec({"cmd":"ls"})'],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "final",
role: "assistant",
content: "Done. Open index.html to play.",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude",
"tool",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "final",
content: "Done. Open index.html to play.",
},
});
});
it("passes assistant turn latency to the preceding completed activity cluster", () => {
const messages: UIMessage[] = [
{
+204 -27
View File
@@ -5,7 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
import { ClientProvider } from "@/providers/ClientProvider";
import type { CliAppsPayload, UIMessage } from "@/lib/types";
import type { CliAppsPayload, SettingsPayload, UIMessage } from "@/lib/types";
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
@@ -62,11 +66,12 @@ function makeClient() {
};
}
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode) {
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
modelName={modelName ?? null}
>
{children}
</ClientProvider>
@@ -106,6 +111,98 @@ function httpJson(body: unknown) {
};
}
function modelSettings(model: string, provider: string): SettingsPayload {
return {
agent: {
model,
provider,
resolved_provider: provider,
has_api_key: true,
model_preset: "default",
max_tokens: 4096,
context_window_tokens: 65536,
temperature: 0.7,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model,
provider,
max_tokens: 4096,
context_window_tokens: 65536,
temperature: 0.7,
reasoning_effort: null,
}],
providers: [
{ name: "deepseek", label: "DeepSeek", configured: true },
{ name: "openai_codex", label: "OpenAI Codex", configured: true },
],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
describe("ThreadShell", () => {
beforeEach(() => {
vi.stubGlobal(
@@ -138,6 +235,87 @@ describe("ThreadShell", () => {
expect(onGoHome).not.toHaveBeenCalled();
});
it("updates the composer model logo when settings snapshot changes", async () => {
const client = makeClient();
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("model-logo")}
title="Model logo"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("deepseek-v4-pro", "deepseek")}
/>,
"deepseek-v4-pro",
),
);
expect(await screen.findByTestId("composer-model-logo-deepseek")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("model-logo")}
title="Model logo"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
/>,
"openai-codex/gpt-5.5",
),
);
});
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
});
it("only shows image generation controls when the setting is enabled", async () => {
const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
const enabledSettings: SettingsPayload = {
...disabledSettings,
image_generation: {
...disabledSettings.image_generation,
enabled: true,
provider_configured: true,
},
};
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("image-generation-disabled")}
title="Image generation disabled"
onToggleSidebar={() => {}}
settingsSnapshot={disabledSettings}
/>,
"deepseek-v4-pro",
),
);
await screen.findByLabelText("Message input");
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("image-generation-disabled")}
title="Image generation disabled"
onToggleSidebar={() => {}}
settingsSnapshot={enabledSettings}
/>,
"deepseek-v4-pro",
),
);
});
expect(screen.getByRole("button", { name: "Toggle image generation mode" })).toBeInTheDocument();
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -337,7 +515,7 @@ describe("ThreadShell", () => {
await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
);
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
expect(screen.queryByText(HERO_GREETING_PATTERN)).not.toBeInTheDocument();
});
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
@@ -418,36 +596,26 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
});
it("sends quick action prompts from the empty thread landing", async () => {
it("keeps the empty thread landing focused on the composer", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onNewChat={() => {}}
/>,
),
);
await act(async () => {});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@@ -653,7 +821,7 @@ describe("ThreadShell", () => {
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
await act(async () => {
rerender(
@@ -814,7 +982,7 @@ describe("ThreadShell", () => {
),
);
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
scrollIntoView.mockClear();
await act(async () => {
@@ -897,8 +1065,9 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
});
it("switches welcome quick actions when image mode is enabled", async () => {
it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek");
render(
wrap(
client,
@@ -907,17 +1076,25 @@ describe("ThreadShell", () => {
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
settingsSnapshot={{
...settings,
image_generation: {
...settings.image_generation,
enabled: true,
provider_configured: true,
},
}}
/>,
),
);
await act(async () => {});
expect(screen.getByText("Write code")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
});
+144 -2
View File
@@ -14,6 +14,10 @@ function fakeClient() {
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
if (ev.event === "turn_end") {
runStartedAtByChatId.delete(chatId);
return;
}
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
runStartedAtByChatId.set(chatId, ev.started_at);
@@ -476,6 +480,69 @@ describe("useNanobotStream", () => {
);
});
it("replaces matching write_file tool events with live file edit activity", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-events", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-events", {
event: "message",
chat_id: "chat-file-edit-events",
text: 'write_file({"path":"foo.txt"})',
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-write",
name: "write_file",
arguments: { path: "foo.txt", content: "hello\n" },
}],
});
fake.emit("chat-file-edit-events", {
event: "file_edit",
chat_id: "chat-file-edit-events",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
phase: "start",
added: 1,
deleted: 0,
approximate: true,
status: "editing",
}],
});
fake.emit("chat-file-edit-events", {
event: "message",
chat_id: "chat-file-edit-events",
text: "",
kind: "progress",
tool_events: [{
phase: "end",
call_id: "call-write",
name: "write_file",
arguments: { path: "foo.txt", content: "hello\n" },
result: "ok",
}],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: [],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "foo.txt",
status: "editing",
}],
});
expect(result.current.messages[0].toolEvents).toBeUndefined();
});
it("upgrades pending file_edit placeholders when the path arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {
@@ -591,7 +658,7 @@ describe("useNanobotStream", () => {
}]);
});
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -625,7 +692,9 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "I created the files.",
content: "",
reasoning: "I created the files.",
isStreaming: false,
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
@@ -638,6 +707,54 @@ describe("useNanobotStream", () => {
});
});
it("does not replace interrupted pre-tool text with final stream_end text", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stream-end-final", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-stream-end-final", {
event: "delta",
chat_id: "chat-stream-end-final",
text: "I will inspect the project first.",
});
fake.emit("chat-stream-end-final", {
event: "stream_end",
chat_id: "chat-stream-end-final",
});
fake.emit("chat-stream-end-final", {
event: "message",
chat_id: "chat-stream-end-final",
text: 'exec({"cmd":"ls"})',
kind: "tool_hint",
});
fake.emit("chat-stream-end-final", {
event: "stream_end",
chat_id: "chat-stream-end-final",
text: "Done. Open index.html to play.",
});
});
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "",
reasoning: "I will inspect the project first.",
isStreaming: false,
});
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['exec({"cmd":"ls"})'],
});
expect(result.current.messages[2]).toMatchObject({
role: "assistant",
content: "Done. Open index.html to play.",
isStreaming: true,
});
});
it("opens a new activity segment for reasoning after file edit activity", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
@@ -1374,6 +1491,31 @@ describe("useNanobotStream", () => {
expect(result.current.runStartedAt).toBeNull();
});
it("clears runStartedAt on turn_end even without idle", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-g", {
event: "goal_status",
chat_id: "chat-g",
status: "running",
started_at: 1700,
});
});
expect(result.current.runStartedAt).toBe(1700);
act(() => {
fake.emit("chat-g", {
event: "turn_end",
chat_id: "chat-g",
});
});
expect(result.current.runStartedAt).toBeNull();
});
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
+25
View File
@@ -186,6 +186,7 @@ describe("useSessions", () => {
await result.current.createChat();
});
expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
await act(async () => {
@@ -204,6 +205,30 @@ describe("useSessions", () => {
expect(result.current.sessions[0]?.title).toBe("Generated title");
});
it("stores optimistic workspace scope when creating a chat", async () => {
vi.mocked(api.listSessions).mockResolvedValue([]);
const client = fakeClient();
client.newChat.mockResolvedValue("chat-workspace");
const workspaceScope = {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.createChat(workspaceScope);
});
expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
});
it("passes through WebUI transcript user media as images and media", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,