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