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:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user