96e4d28f7c
- #69: tmux applies a pane's `-c` only while its server's own cwd exists. The server was forked from the dashboard's cwd (the plugin cache dir), outlives updates, and the old cache dir is pruned — after that every new pane started in a deleted directory, where `claude` died at once ("run not found") and a shell printed getcwd errors. tmux calls now run from `/`, and the pane's sh wrapper cds into its folder itself, which also heals a tmux server that is already stuck. - Changes panel: a workspace's extra folders are sections stacked under the workspace folder (VS Code Source Control style), with Close repository / Close other repositories on a header's right-click. - Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]` line folds into its turn; uploads under `<data dir>/drops/` are listed. - Update check: a checkout holding commits a newer release lacks reports `diverged_from_release` instead of offering a fast-forward that can only fail.
305 lines
11 KiB
TypeScript
305 lines
11 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.
|
|
* @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.
|
|
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 },
|
|
]
|
|
: [],
|
|
})
|
|
),
|
|
// 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 }],
|
|
}),
|
|
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: () => "" },
|
|
},
|
|
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(() => {
|
|
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();
|
|
|
|
// Full width HIDES the console beside it — hidden, still mounted.
|
|
fireEvent.click(size());
|
|
expect(card().className).toContain("w-full");
|
|
expect(screen.getByTestId("lane-detail").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(screen.getByTestId("lane-detail").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("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 });
|
|
// A folded section keeps its panel mounted, only hidden.
|
|
const toggle = sections[0]!.querySelector("button")!;
|
|
fireEvent.click(toggle);
|
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
|
// 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("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();
|
|
});
|
|
});
|