Files
AgentHub/client/src/components/run/__tests__/LaneConsolePane.test.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

362 lines
12 KiB
TypeScript

/**
* @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component: a tab's body shows
* its lane's terminal or an empty state whose New session goes to the
* workspace home — no run form and no header of its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
// out of claude), which the real TerminalView reports through `onExit`.
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
<div data-testid="terminal-view" data-run-id={runId}>
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
</div>
),
}));
vi.mock("../PaneChat", () => ({
PaneChat: ({ sessionId }: { sessionId: string }) => (
<div data-testid="pane-chat" data-session-id={sessionId} />
),
}));
vi.mock("../../../lib/api", () => ({
api: {
me: {
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
savePrefs: vi.fn(),
},
lanes: {
ensure: vi.fn(),
action: vi.fn(),
list: vi.fn(),
},
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
get: vi.fn(),
start: vi.fn(),
},
},
RUN_MODEL_CHOICES: [],
RUN_EFFORT_CHOICES: [],
}));
const LANE: Lane = {
id: 1,
router_profile: null,
title: "demo",
cwd: "/workspace/a",
branch: null,
kind: "adopted",
tool: "claude",
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" as Lane["liveness"],
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
};
function baseProps() {
return {
lanes: [LANE],
laneId: 1,
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
activeRuns: { items: [] },
};
}
describe("LaneConsolePane", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("has no run form: an empty pane's New session goes to the workspace home", () => {
const onNewSession = vi.fn();
render(<LaneConsolePane {...baseProps()} onNewSession={onNewSession} />);
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "New session" }));
expect(onNewSession).toHaveBeenCalledOnce();
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("has no header: no title, no Active runs, no New run - the tab bar is the header", async () => {
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
const onRunExit = vi.fn();
render(
<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} onRunExit={onRunExit} />
);
await screen.findByTestId("terminal-view");
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.queryByRole("button", { name: "New run" })).toBeNull();
expect(screen.queryByRole("button", { name: /active run/i })).toBeNull();
// The run exits: the tab bar is told, and the pane is left empty.
fireEvent.click(screen.getByTestId("fake-pane-exit"));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
expect(onRunExit).toHaveBeenCalledOnce();
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
});
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
// Unmounting the terminal would drop the attach the chat sends through.
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
__resetChatUi();
});
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
vi.mocked(api.me.prefs).mockResolvedValueOnce({
prefs: { "agenthub.chatUi": "1" },
onboardedAt: null,
});
__resetChatUi();
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-1",
startedAt: null,
promptPreview: null,
tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
// Seen once, the session keeps chat: turning the default off leaves it alone.
await waitFor(() =>
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
);
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
await act(() => saveChatUi(false));
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
// Its own toggle switches it.
act(() => setSessionChat("s-1", false));
expect(screen.queryByTestId("pane-chat")).toBeNull();
__resetChatUi();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({
id,
laneId,
status: "running" as const,
cwd: null,
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "claude" as const,
});
const props = {
...baseProps(),
lanes: [LANE, LANE2],
activeRuns: { items: [run("agenthub-lane-1", 1), run("agenthub-lane-2", 2)] },
};
const { rerender } = render(<LaneConsolePane {...props} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-1")
);
rerender(<LaneConsolePane {...props} laneId={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2")
);
// A lane with no live run falls back to an empty pane, not the previous
// lane's terminal.
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
it("renders the console body when laneId is null (fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
});
describe("tabs on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
tool: "claude" as const,
});
it("attaches a pane only to its own slot's session", async () => {
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-2"
)
);
});
});
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-2",
laneId: 1,
slot: 2,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-1-2", nonce: "k1" }}
/>
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.lanes.action).not.toHaveBeenCalled();
expect(api.run.start).not.toHaveBeenCalled();
});
it("attaches once the lane arrives on a cold load, and ignores another lane's run", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
const req = { runId: "agenthub-lane-1-1", nonce: "k2" };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} laneId={null} attachRequest={req} />
</MemoryRouter>
);
// The lane list lands only after the pane mounted (and after any early
// attach would have resolved) — the switch effect must not win.
await new Promise((r) => setTimeout(r, 20));
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} attachRequest={req} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-1");
vi.mocked(api.run.get).mockClear();
rerender(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-12", nonce: "k3" }}
/>
</MemoryRouter>
);
await new Promise((r) => setTimeout(r, 10));
expect(api.run.get).not.toHaveBeenCalled();
});
});