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
+25 -32
View File
@@ -12,6 +12,8 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function jsonResponse(body: unknown): Response {
return {
@@ -97,6 +99,9 @@ function baseSettingsPayload() {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -412,29 +417,7 @@ describe("App layout", () => {
const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Compact list"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
});
fireEvent.click(screen.getByText("Title A-Z"));
await waitFor(() => {
const lastUpdateUrl = vi.mocked(fetch).mock.calls
.map(([url]) => String(url))
.filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
.at(-1);
const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
});
expect(within(sidebar).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
});
it("sorts chats by displayed title when A-Z is persisted", async () => {
@@ -785,6 +768,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -828,8 +814,8 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
@@ -906,9 +892,13 @@ describe("App layout", () => {
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
fireEvent.click(within(settingsNav).getByRole("button", { name: "System" }));
expect(screen.getByText("Bot name")).toBeInTheDocument();
expect(screen.queryByText("Tool hint length")).not.toBeInTheDocument();
expect(screen.queryByText("Heartbeat")).not.toBeInTheDocument();
expect(screen.queryByText("Dream")).not.toBeInTheDocument();
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
@@ -1071,6 +1061,9 @@ describe("App layout", () => {
},
advanced: {
restrict_to_workspace: false,
webui_allow_local_service_access: true,
webui_default_access_mode: "default",
private_service_protection_enabled: true,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
@@ -1097,7 +1090,7 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
});
it("filters sessions in the centered search dialog", async () => {
@@ -1266,23 +1259,23 @@ describe("App layout", () => {
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("56px"));
const sidebarAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(sidebarAside.style.width).toBe("56px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(rail).queryByRole("button", { name: "View" })).not.toBeInTheDocument();
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
await waitFor(() => expect(sidebarAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Settings" })).toBeInTheDocument();