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
+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();
});