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:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
@@ -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"));
});
});