b0dd5af22f
- 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.
616 lines
23 KiB
TypeScript
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"));
|
|
});
|
|
});
|