Files
AgentHub/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx
T
nntrivi2001 b0dd5af22f fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer
- Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not
  part of the collapsed work line — each question with the option picked or
  the text typed, "declined" when refused. The transcript route passes the
  answers from Claude Code's toolUseResult (askAnswers, beside diff).
- Chat: the activity panel keeps the user's open/close per session for the
  tab, and opens by default only once the session has an output.
- Workspace home: the sent prompt's draft is dropped even though opening the
  session unmounts the home.
- Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty
  clipboard still sends ^V.
- AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating,
  the full suite once before committing.
2026-10-13 10:46:56 +07:00

616 lines
23 KiB
TypeScript

/**
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, on the AI tool picked for it (the workspace's by
* default), AI and Model in the box and AI Proxy / Permission / Worktree
* under it (the proxy's models on a proxy), 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 { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
vi.mock("../../run/RunSetup", () => ({
promptWithImages: (text: string, images: { path: string }[]) =>
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
PromptEditor: ({
value,
onChange,
onSubmit,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
}) => (
<textarea
aria-label={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onSubmit();
}}
/>
),
ModelMenu: ({
value,
onChange,
choices,
}: {
value: string;
onChange: (s: string) => void;
choices?: { id: string }[];
}) => (
<input
aria-label="model"
data-choices={choices ? choices.map((c) => c.id).join(",") : "tool"}
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", cost: 1.234 },
{ 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 })),
patch: vi.fn(async () => ({ session: {} })),
},
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: () => "" },
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
},
};
});
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();
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions,
total: sessions.length,
limit: 30,
offset: 0,
}));
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, 4, 5, 6, 7, 8, 9].map(
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
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: "auto",
prompt: "fix the login",
permissionMode: "auto",
});
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=1&run=agenthub-lane-1-1"
)
);
});
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
const user = userEvent.setup();
const first = renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
first.unmount();
const other = renderHome(lane({ id: 2 }));
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
other.unmount();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("drops the sent prompt's draft even though opening the session unmounts the home", async () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
}
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route path="*" element={<HomeUntilRun />} />
</Routes>
</MemoryRouter>
);
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "sent once");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(screen.queryByTestId("workspace-home-start")).toBeNull());
expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull();
});
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const labels = (id: string) =>
[
...screen
.getByTestId(id)
.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
].map((x) => x.getAttribute("aria-label"));
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
expect(labels("workspace-home-options")).toEqual([
"AI Proxy",
"Permission mode",
expect.stringContaining("own git worktree"),
]);
});
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
].map((x) => ({ installed: true, enabled: true, ...x })),
} as never);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const ai = screen.getByTestId("workspace-home-tool");
await user.click(await within(ai).findByText("Claude Code"));
// Not installed: not offered.
expect(screen.queryByText("Cursor CLI")).toBeNull();
await user.click(screen.getByText("Codex"));
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "port it",
tool: "codex",
sandbox: "workspace-write",
approval: "on-request",
});
vi.mocked(api.aiTools.list).mockResolvedValue({ tools: [], default: "claude" } as never);
__resetAiTools();
});
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
localStorage.setItem("agenthub.session.proxy", "glm");
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
items: [
{ name: "native", label: "Native", builtin: true, models: {} },
{
name: "glm",
label: "GLM",
builtin: false,
model: "glm-4.6",
models: { opus: "glm-4.6", sonnet: "glm-4.5", haiku: "glm-4.5-air" },
},
],
} as never);
const user = userEvent.setup();
renderHome();
await waitFor(() =>
expect(screen.getByLabelText("model")).toHaveAttribute(
"data-choices",
"glm-4.6,glm-4.5,glm-4.5-air"
)
);
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ proxy: "glm" })
);
});
it("starts the session in its own worktree when New worktree is picked (a repo only)", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.click(
within(await screen.findByTestId("workspace-home-worktree")).getByRole("button")
);
await user.click(screen.getByText("New worktree"));
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("opens a terminal tab at once from the AI menu, without a prompt", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
const ai = screen.getByTestId("workspace-home-tool");
await user.click(within(ai).getByRole("button"));
await user.click(screen.getByRole("option", { name: /Open terminal/ }));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toMatch(/^\/workspace\?lane=1&run=/)
);
// The AI picked for the next prompt is untouched.
expect(within(ai).getByRole("button")).not.toHaveTextContent(/terminal/i);
});
it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
let release: (v: { run_id: string }) => void = () => undefined;
vi.mocked(api.lanes.action).mockImplementationOnce(
() => new Promise((r) => (release = r)) as never
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
const box = screen.getByLabelText("How can I help you today?");
expect(screen.getByTestId("workspace-home-start")).toBeDisabled();
await user.type(box, "{Enter}");
expect(api.lanes.action).not.toHaveBeenCalled();
await user.type(box, "go{Enter}{Enter}");
release({ run_id: "agenthub-lane-1-1" });
await waitFor(() => expect(api.lanes.action).toHaveBeenCalledTimes(1));
});
it("folds the archived sessions with Unarchive", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
sessions: (params as { archived?: boolean }).archived ? [sessions[1]!] : [],
total: (params as { archived?: boolean }).archived ? 1 : 0,
limit: 30,
offset: 0,
}));
const user = userEvent.setup();
renderHome();
const fold = await screen.findByTestId("workspace-archived-1");
await user.click(within(fold).getByRole("button", { name: /1 archived session/ }));
await user.click(within(fold).getByTestId("workspace-archived-1-action-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
});
it("shows more recents a page at a time, and collapses them again", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
const all = (params as { archived?: boolean }).archived
? []
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
const limit = (params as { limit?: number }).limit ?? 30;
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
});
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByTestId("workspace-recents-more"));
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
await user.click(await screen.findByTestId("workspace-recents-less"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
});
it("opens a live recent's pane and an ended one's conversation, 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"));
// Recents are the workspace's OWN sessions, not the folder's.
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 30 });
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("/workspace?lane=1&session=s-old");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("names the repository it belongs to: root, main branch, worktree count", async () => {
renderHome();
const facts = await screen.findByTestId("workspace-home-facts");
expect(facts).toHaveTextContent("/r");
expect(facts).toHaveTextContent("main");
expect(facts).toHaveTextContent("3 worktrees");
});
it("shows no repository line for a workspace outside any repository", async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce({
projects: [{ root: null, name: "plain", workspaces: [1], worktrees: [] }],
} as never);
renderHome();
await waitFor(() => expect(api.projects.list).toHaveBeenCalled());
expect(screen.queryByTestId("workspace-home-facts")).toBeNull();
});
it("shows no repository line for a folder in no git project", async () => {
renderHome(lane({ id: 9, cwd: "/plain" }));
await waitFor(() => expect(api.projects.list).toHaveBeenCalled());
expect(screen.queryByTestId("workspace-home-facts")).toBeNull();
});
it("shows a recent's cost when it has one", async () => {
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
expect(live).toHaveTextContent("$1.23");
expect(screen.getByTestId("workspace-recent-s-old")).not.toHaveTextContent("$");
});
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);
const a = new File(["a"], "a.csv", { type: "text/csv" });
const b = new File(["b"], "b.log", { type: "" });
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
dataTransfer: { types: ["Files"], files: [a, b] },
});
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
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();
});
it("shows one memory file per tab", async () => {
vi.mocked(api.lanes.memory).mockResolvedValueOnce({
files: [
{
label: "CLAUDE.md",
path: "/r/CLAUDE.md",
content: "# Rules here",
size: 0,
mtime: "",
truncated: false,
},
{
label: "memory/a.md",
path: "/m/a.md",
content: "# Note A",
size: 0,
mtime: "",
truncated: false,
},
],
});
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("2 files the agent reads")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View memory" }));
const dialog = screen.getByRole("dialog", { name: "Memory" });
expect(within(dialog).getByRole("tab", { name: "CLAUDE.md" })).toHaveAttribute(
"aria-selected",
"true"
);
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
expect(within(dialog).queryByText("Note A")).toBeNull();
await user.click(within(dialog).getByRole("tab", { name: "memory/a.md" }));
expect(within(dialog).getByText("Note A")).toBeInTheDocument();
expect(within(dialog).queryByText("Rules here")).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", launcher: "claude", defaultModel: "sonnet" }],
default: "claude",
} as never);
renderHome();
await waitFor(() => expect(screen.getByLabelText("model")).toHaveValue("sonnet"));
});
});