fa156d6a59
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.
362 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|