fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
/**
|
|
* @file WorkspaceTree.owners.test.tsx
|
|
* @description Server mode, the sidebar workspace list for an analyst: every
|
|
* user's workspaces, grouped under their owner (yours first, no owner last),
|
|
* groups folding and remembered; another user's workspace is read only — no
|
|
* menu, no new session, and a session opens its analysis page, never a pane.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
|
import type { Lane, Session } from "../../lib/types";
|
|
|
|
const ME = { id: 2, username: "ana", role: "analyst" as const };
|
|
|
|
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
|
|
({
|
|
id,
|
|
title,
|
|
cwd: `/w/${id}`,
|
|
kind: "adopted",
|
|
tool: "claude",
|
|
status: "idle",
|
|
liveness: "idle",
|
|
pipeline_nodes: [],
|
|
owner_id: owner,
|
|
owner_name: name,
|
|
}) as unknown as Lane;
|
|
|
|
const lanes = [
|
|
lane(1, 3, "bob", "Bob app"),
|
|
lane(2, null, null, "Old"),
|
|
lane(3, ME.id, "ana", "Mine"),
|
|
lane(4, 1, "admin", "Admin site"),
|
|
];
|
|
|
|
vi.mock("../AuthGate", () => ({
|
|
useAuth: () => ({ state: { mode: "server", user: ME } }),
|
|
usePermissions: () => ({
|
|
isAdmin: false,
|
|
seesAll: true,
|
|
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
|
|
}),
|
|
}));
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
|
|
sessions: {
|
|
list: vi.fn(async () => ({
|
|
sessions: [
|
|
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
|
|
] as Session[],
|
|
total: 1,
|
|
limit: 6,
|
|
offset: 0,
|
|
})),
|
|
},
|
|
run: { list: vi.fn(async () => ({ items: [] })) },
|
|
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
|
},
|
|
};
|
|
});
|
|
|
|
import { WorkspaceTree } from "../WorkspaceTree";
|
|
|
|
function Where() {
|
|
const loc = useLocation();
|
|
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
|
}
|
|
|
|
function renderTree() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<Routes>
|
|
<Route
|
|
path="*"
|
|
element={
|
|
<>
|
|
<WorkspaceTree />
|
|
<Where />
|
|
</>
|
|
}
|
|
/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
beforeEach(() => localStorage.clear());
|
|
|
|
describe("WorkspaceTree for an analyst", () => {
|
|
it("groups every workspace under its owner: yours first, no owner last", async () => {
|
|
renderTree();
|
|
await screen.findByTestId("workspace-row-1");
|
|
const order = screen
|
|
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
|
|
.map((el) => el.dataset.testid);
|
|
expect(order).toEqual([
|
|
"workspace-owner-2",
|
|
"workspace-row-3",
|
|
"workspace-owner-1",
|
|
"workspace-row-4",
|
|
"workspace-owner-3",
|
|
"workspace-row-1",
|
|
"workspace-owner-none",
|
|
"workspace-row-2",
|
|
]);
|
|
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
|
});
|
|
|
|
it("folds a group and remembers it", async () => {
|
|
const user = userEvent.setup();
|
|
renderTree();
|
|
await user.click(await screen.findByTestId("workspace-owner-3"));
|
|
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
|
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
|
|
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
|
|
"3": true,
|
|
});
|
|
});
|
|
|
|
it("keeps another user's workspace read only", async () => {
|
|
const user = userEvent.setup();
|
|
renderTree();
|
|
await screen.findByTestId("workspace-row-1");
|
|
// Yours keeps its controls; theirs has none.
|
|
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
|
|
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
|
|
// Picking it unfolds it instead of opening a workspace home.
|
|
await user.click(screen.getByTestId("workspace-open-1"));
|
|
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
|
|
const list = await screen.findByTestId("workspace-sessions-1");
|
|
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
|
|
await user.click(within(list).getByTestId("workspace-session-s-1"));
|
|
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
|
|
});
|
|
});
|