Files
AgentHub/client/src/pages/__tests__/Workspace.changesPanel.test.tsx
T
nntrivi2001 ab27629139 feat(workspace): the Changes panel shows on the workspace home too
The home used to hide the whole row the Changes panel lives in, so a
workspace's changes showed only once a session tab was open. The home now
shares that row: the panel (or its rail) sits beside it at the same width,
the page is locked to the viewport and the home scrolls in its own column.
The panel's Workspace context fold is left out there, since the home shows
that column itself. The console's section stays mounted and hidden (CSS).
2026-11-06 09:42:08 +07:00

437 lines
17 KiB
TypeScript

/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel has two controls: Full screen (Restore at
* full screen, back to the last dragged width) and Collapse; the rail reopens
* it at the last dragged width, else 25/75. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order. Its bottom fold is the workspace's context, compact.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, cleanup, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
// fixtures it reads have to be lifted with it.
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
const laneStub = (id: number, cwd: string) => ({
id,
title: `lane-${id}`,
cwd,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return {
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
LANE_A: laneStub(1, "/workspace/alpha"),
LANE_B: laneStub(2, "/workspace/beta"),
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
// Local mode unless a test says otherwise.
const perms = vi.hoisted(() => ({ local: true }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
});
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
freeSlot: () => 0,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
run: {
list: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running" }),
},
lanes: {
list: r({
lanes: [LANE_A, LANE_B],
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
// Workspace 1 has one extra folder, its own repo (`?source=`).
sources: vi.fn((id: number) =>
Promise.resolve({
sources:
id === 1
? [
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
]
: [],
})
),
// The repos inside the workspace folder (`?repo=`): none by default.
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
// Closing a folder's section removes it from the workspace.
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
}),
memory: r({ files: [] }),
gitTree: r({ entries: [] }),
// A file opened in the panel on a chat's request.
changes: r({ available: false }),
gitFile: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
// The File Explorer opened from the panel header.
files: {
homes: r({ homes: [] }),
list: r({ path: "", parent: null, entries: [], truncated: false }),
trash: r({ items: [] }),
},
aiProxies: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
import { openInChanges } from "../../lib/openInChanges";
import { api } from "../../lib/api";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
perms.local = true;
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Workspace — the Changes panel's width", () => {
beforeEach(() => window.localStorage.clear());
async function openPanel() {
render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
// Open/closed is itself remembered, so the second mount of a test comes up
// already open — clicking the rail then would close it.
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
}
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
const size = () => screen.getByTestId("lane-changes-resize");
expect(card().className).toContain("w-[25%]");
expect(size()).toHaveAttribute("aria-label", "Full screen");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// The workspace's home sits beside the panel (no session is open), and
// full width HIDES it — hidden, still mounted; same for a console.
const beside = () => screen.getByTestId("workspace-home-column");
expect(beside().className).not.toContain("hidden");
fireEvent.click(size());
expect(card().className).toContain("w-full");
expect(beside().className).toContain("hidden");
expect(size()).toHaveAttribute("aria-label", "Restore size");
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
// Restore brings the console back beside it.
fireEvent.click(size());
expect(card().className).toContain("w-[25%]");
expect(beside().className).not.toContain("hidden");
// Collapsing from full screen reopens at 25/75, not back at full width.
fireEvent.click(size());
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[25%]");
});
it("opens the File Explorer from the button beside Full screen", async () => {
perms.local = false;
await openPanel();
const btn = screen.getByTestId("panel-file-explorer");
// Right before the window controls, in the same header strip.
expect(btn.nextElementSibling).toBe(screen.getByTestId("lane-changes-resize"));
fireEvent.click(btn);
expect(await screen.findByTestId("file-explorer")).toBeInTheDocument();
});
it("has no File Explorer button in local mode", async () => {
await openPanel();
expect(screen.queryByTestId("panel-file-explorer")).toBeNull();
expect(screen.getByTestId("lane-changes-resize")).toBeInTheDocument();
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
cleanup();
await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
});
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
});
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
});
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
});
await settle();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
});
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
vi.mocked(api.lanes.git).mockClear();
await openPanel();
const sections = screen.getAllByTestId("panel-git-source");
// The workspace folder, then its folders — never a context file.
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
expect(api.lanes.git).toHaveBeenCalledWith(1);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
// An accordion: the first section starts open, the others folded.
const toggle = sections[0]!.querySelector("button")!;
const shop = sections[1]!.querySelector("button")!;
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(shop).toHaveAttribute("aria-expanded", "false");
// Opening one folds the other; a folded section keeps its panel mounted.
fireEvent.click(shop);
expect(shop).toHaveAttribute("aria-expanded", "true");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
// Clicking the open one folds it too.
fireEvent.click(shop);
expect(shop).toHaveAttribute("aria-expanded", "false");
// A chat's file is the workspace folder's: its section unfolds.
act(() => {
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
});
await settle();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
});
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
await openPanel();
const [main, shop] = screen.getAllByTestId("panel-git-source");
fireEvent.contextMenu(main!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.contextMenu(shop!.querySelector("button")!);
fireEvent.click(screen.getByTestId("panel-git-source-close"));
await settle();
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
// The last folder gone: back to the one plain panel.
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("folds the workspace context at the bottom, and a folder removed there leaves the stack", async () => {
await openPanel();
const fold = screen.getByTestId("panel-workspace-context");
const toggle = fold.querySelector("button")!;
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("workspace-context")).toBeNull();
fireEvent.click(toggle);
await settle();
expect(window.localStorage.getItem("agenthub.workspace.contextOpen")).toBe("1");
expect(screen.getByText("notes.md")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove shop" }));
await settle();
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
vi.mocked(api.lanes.repos).mockClear();
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [{ path: "api", name: "api" }],
truncated: false,
});
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
expect(api.lanes.repos).not.toHaveBeenCalled();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
// Settings → Git → Changes panel: show the repositories inside.
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [
{ path: "api", name: "api" },
{ path: "apps/web", name: "apps/web" },
],
truncated: false,
});
vi.mocked(api.lanes.git).mockClear();
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
const sections = screen.getAllByTestId("panel-git-source");
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
"api",
"apps/web",
]);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
// An inner repo is the folder's own: its menu rescans, never closes it.
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
// A chat's file inside an inner repo opens in that repo's section.
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
});
await settle();
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
expect(api.lanes.gitFile).toHaveBeenCalledWith(
{ lane: 2, repo: "apps/web" },
"src/x.ts",
"work"
);
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("collapses from its narrow width too, back to the rail", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
});
});