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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("网页搜索");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user