feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt box whose Start opens a new session in the first free pane slot, Recents (a live session attaches its pane, any other opens its transcript, nothing resumes), and Instructions / Memory / Context / Folder. Instructions live in lanes.instructions, extra folders and uploaded files in the new lane_sources table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads them from the lane's rows and hands Claude --add-dir + an appended system prompt file, Codex --add-dir + developer_instructions. Memory is read-only. Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run records which session the pane holds (a Claude pane follows /clear and /resume but not a nested claude -p), so the sidebar and notifications open the right pane (&run=). Settings loses its Projects group; Worktrees now decides which worktrees Add folder suggests.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @file WorkspaceHome.test.tsx
|
||||
* @description The workspace home: a new session starts in the first free
|
||||
* pane slot and opens it, all-busy is refused, Recents open a live pane or the
|
||||
* transcript (never a resume), instructions save, context files upload and
|
||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||
* read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, 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";
|
||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../run/RunSetup", () => ({
|
||||
PromptEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
ModelPicker: ({ value, onChange }: { value: string; onChange: (s: string) => void }) => (
|
||||
<input aria-label="model" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
let view: LaneSourcesView;
|
||||
let runs: RunHandle[];
|
||||
const sessions = [
|
||||
{ id: "s-live", name: "Live one", started_at: "2026-09-30T10:00:00Z" },
|
||||
{ id: "s-old", name: "Old one", started_at: "2026-09-29T10:00:00Z" },
|
||||
] as Session[];
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
sources: vi.fn(async () => view),
|
||||
setInstructions: vi.fn(async (_id: number, instructions: string) => ({
|
||||
...view,
|
||||
instructions,
|
||||
})),
|
||||
addFolder: vi.fn(async () => view),
|
||||
addFile: vi.fn(async () => view),
|
||||
removeSource: vi.fn(async () => view),
|
||||
memory: vi.fn(async () => ({
|
||||
files: [{ label: "CLAUDE.md", path: "/r/CLAUDE.md", content: "# Rules here" }],
|
||||
})),
|
||||
action: vi.fn(async () => ({ ok: true, run_id: "agenthub-lane-1-1" })),
|
||||
update: vi.fn(async () => ({})),
|
||||
routerList: vi.fn(async () => ({ routers: [] })),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: runs })) },
|
||||
sessions: { list: vi.fn(async () => ({ sessions })) },
|
||||
projects: {
|
||||
list: vi.fn(async () => ({
|
||||
projects: [
|
||||
{
|
||||
root: "/r",
|
||||
name: "r",
|
||||
workspaces: [1],
|
||||
worktrees: [
|
||||
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
|
||||
{
|
||||
path: "/r/.claude/worktrees/x",
|
||||
branch: "x",
|
||||
main: false,
|
||||
workspace_id: null,
|
||||
source: "claude",
|
||||
},
|
||||
{ path: "/wt/feat", branch: "feat", main: false, workspace_id: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { WorkspaceHome, freeSlot, paneRunId } from "../WorkspaceHome";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
const lane = (extra: Partial<Lane> = {}) =>
|
||||
({
|
||||
id: 1,
|
||||
title: "SG_MiniApp",
|
||||
cwd: "/r",
|
||||
tool: "claude",
|
||||
kind: "adopted",
|
||||
status: "idle",
|
||||
run_id: null,
|
||||
session_id: null,
|
||||
...extra,
|
||||
}) as unknown as Lane;
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
|
||||
function renderHome(l = lane()) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
view = {
|
||||
instructions: "",
|
||||
supported: true,
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
maxInstructions: 16000,
|
||||
sources: [],
|
||||
};
|
||||
runs = [
|
||||
{
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot: 0,
|
||||
status: "running",
|
||||
sessionId: "s-live",
|
||||
} as RunHandle,
|
||||
];
|
||||
});
|
||||
|
||||
describe("freeSlot / paneRunId", () => {
|
||||
it("picks the first slot no live run of this lane holds", () => {
|
||||
expect(freeSlot(1, [])).toBe(0);
|
||||
expect(freeSlot(1, runs)).toBe(1);
|
||||
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
||||
expect(freeSlot(1, all)).toBeNull();
|
||||
expect(freeSlot(2, all)).toBe(0);
|
||||
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
|
||||
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceHome", () => {
|
||||
it("starts a new session in the first free pane and opens it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
prompt: "fix the login",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("where").textContent).toBe(
|
||||
"/workspace?lane=1&run=agenthub-lane-1-1"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to start when all four panes are busy", async () => {
|
||||
runs = [0, 1, 2, 3].map(
|
||||
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
const live = await screen.findByTestId("workspace-recent-s-live");
|
||||
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
|
||||
await user.click(live);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await user.click(screen.getByTestId("workspace-recent-s-old"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves instructions", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit instructions" }));
|
||||
await user.type(screen.getByTestId("workspace-instructions"), "Answer in Vietnamese");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(api.lanes.setInstructions).toHaveBeenCalledWith(1, "Answer in Vietnamese");
|
||||
expect(await screen.findByText("Answer in Vietnamese")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploads a context file and removes one", async () => {
|
||||
view.sources = [
|
||||
{
|
||||
id: 7,
|
||||
kind: "file",
|
||||
path: "/d/spec.pdf",
|
||||
name: "spec.pdf",
|
||||
size: 2048,
|
||||
added_at: "",
|
||||
missing: false,
|
||||
},
|
||||
];
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
expect(await screen.findByText("spec.pdf")).toBeInTheDocument();
|
||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
||||
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
});
|
||||
|
||||
it("adds a folder, suggesting untracked worktrees minus hidden sources", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.sidebar.projects",
|
||||
JSON.stringify({ hideSources: ["claude"], showUntracked: true })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await user.click(await screen.findByRole("button", { name: "Add folder" }));
|
||||
await user.click(await screen.findByRole("button", { name: "/wt/feat" }));
|
||||
expect(screen.queryByRole("button", { name: "/r/.claude/worktrees/x" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(api.lanes.addFolder).toHaveBeenCalledWith(1, "/wt/feat");
|
||||
});
|
||||
|
||||
it("says when the workspace's tool cannot take sources", async () => {
|
||||
view.supported = false;
|
||||
renderHome(lane({ tool: "gemini" as Lane["tool"] }));
|
||||
expect(await screen.findByText(/cannot take instructions/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows memory read-only in a dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
expect(await screen.findByText("1 file the agent reads")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "View memory" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Memory" });
|
||||
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceHome across workspaces", () => {
|
||||
it("drops the previous workspace's prompt and model when another opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ui = (l: Lane) => (
|
||||
<MemoryRouter>
|
||||
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const { rerender } = render(ui(lane()));
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "half typed");
|
||||
await user.type(screen.getByLabelText("model"), "opus");
|
||||
rerender(ui(lane({ id: 2, tool: "codex", cwd: "/other" })));
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
|
||||
expect(screen.getByLabelText("model")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("starts on the tool's default model once the catalog arrives", async () => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockResolvedValueOnce({
|
||||
tools: [{ id: "claude", label: "Claude", defaultModel: "sonnet" }],
|
||||
default: "claude",
|
||||
} as never);
|
||||
renderHome();
|
||||
await waitFor(() => expect(screen.getByLabelText("model")).toHaveValue("sonnet"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user