From b1d43bf0987cf2655e4251e6f13bb3a3b951032f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 14 Aug 2026 11:36:38 +0700 Subject: [PATCH] feat(workspace): extract LaneConsolePane from the inline run console --- client/src/components/run/LaneConsolePane.tsx | 374 ++++++++++++++++++ .../run/__tests__/LaneConsolePane.test.tsx | 141 +++++++ client/src/i18n/locales/en/lanes.json | 3 + client/src/i18n/locales/vi/lanes.json | 3 + 4 files changed, 521 insertions(+) create mode 100644 client/src/components/run/LaneConsolePane.tsx create mode 100644 client/src/components/run/__tests__/LaneConsolePane.test.tsx diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx new file mode 100644 index 0000000..ee91eef --- /dev/null +++ b/client/src/components/run/LaneConsolePane.tsx @@ -0,0 +1,374 @@ +/** + * @file LaneConsolePane.tsx + * @description One lane's run console: the RunSetup ↔ TerminalView switcher, + * moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of + * these side by side (split terminal view). Owns its own prompt/cwd/model/ + * permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing + * is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`, and + * `activeRuns` are supplied as props because they are global, not + * lane-specific, and fetching them per pane would mean N redundant identical + * requests for an N-pane layout. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, AlertCircle } from "lucide-react"; +import { api } from "../../lib/api"; +import type { + CwdSuggestion, + DashboardRunHistoryItem, + EffortLevel, + PermissionMode, + RunHandle, + RunListResponse, + RunStartArgs, +} from "../../lib/api"; +import type { Session, Lane } from "../../lib/types"; +import { TerminalView } from "./TerminalView"; +import { RunSetup } from "./RunSetup"; +import { ActiveRunsSwitcher } from "./RunHistory"; + +export interface LaneConsolePaneProps { + lanes: Lane[]; + laneId: number | null; + showLaneSelector: boolean; + onLaneIdChange: (id: number) => void; + onLaneCreated: (lane: Lane) => void; + binaryStatus: { found: boolean; path: string | null } | null; + cwdSuggestions: CwdSuggestion[]; + activeRuns: RunListResponse | null; + wsConnected: boolean; +} + +export function LaneConsolePane({ + lanes, + laneId, + showLaneSelector, + onLaneIdChange, + onLaneCreated, + binaryStatus, + cwdSuggestions, + activeRuns, + wsConnected, +}: LaneConsolePaneProps) { + const { t } = useTranslation("run"); + const { t: tLanes } = useTranslation("lanes"); + + const [prompt, setPrompt] = useState(""); + const [model, setModel] = useState(""); + const [permissionMode, setPermissionMode] = useState("acceptEdits"); + const [effort, setEffort] = useState(""); + const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? ""); + const [resumeSession, setResumeSession] = useState(null); + const [handle, setHandle] = useState(null); + const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null); + const [error, setError] = useState(null); + const [runHistory, setRunHistory] = useState([]); + + const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; + + const refreshList = useCallback(() => { + if (laneId !== null) { + api.run + .history(50, { laneId }) + .then((r) => setRunHistory(r.items)) + .catch(() => undefined); + } else { + api.run + .history(50) + .then((r) => setRunHistory(r.items)) + .catch(() => undefined); + } + }, [laneId]); + + const attachToRun = useCallback( + async (id: string) => { + if (busy) return; + setBusy("attach"); + setError(null); + try { + const fetched = await api.run.get(id); + setHandle(fetched); + } catch (err: unknown) { + const m = err instanceof Error ? err.message : "unknown"; + setError(t("errors.attachFailed", { message: m })); + } finally { + setBusy(null); + } + }, + [busy, t] + ); + + const onStartFromSetup = useCallback( + async (args: RunStartArgs) => { + if (busy) return; + setBusy("start"); + setError(null); + try { + const effectiveCwd = args.cwd || undefined; + + if (!effectiveCwd) { + throw new Error(t("errors.cwdRequired")); + } + + // Resolve the lane from the cwd the user actually typed, not from + // args.laneId — RunSetup always supplies this pane's laneId (a + // required prop), which would otherwise silently start a run in the + // wrong lane whenever the user types a cwd different from the one + // this pane currently shows. + const ownedLane = lanes.find((l) => l.cwd === effectiveCwd); + let targetLaneId: number; + if (ownedLane) { + targetLaneId = ownedLane.id; + if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id); + } else { + try { + const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + targetLaneId = ensureResult.lane.id; + onLaneIdChange(ensureResult.lane.id); + onLaneCreated(ensureResult.lane); + } catch (err) { + throw new Error( + t("errors.laneCreateFailed", { + message: err instanceof Error ? err.message : "unknown", + }) + ); + } + } + + let laneStartResult; + try { + laneStartResult = await api.lanes.action(targetLaneId, "start", { + prompt: args.initialPrompt || "", + model: args.model || undefined, + permissionMode: args.permissionMode, + resumeSessionId: args.resumeSessionId, + effort: args.effort || undefined, + }); + } catch (laneErr: unknown) { + const msg = laneErr instanceof Error ? laneErr.message : String(laneErr); + if (msg.includes("409") || msg.includes("ERUNLIVE")) { + const fresh = await api.lanes.list().catch(() => null); + const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId); + if (updatedLane?.run_id) { + await attachToRun(updatedLane.run_id); + return; + } + } + throw laneErr; + } + + if (!laneStartResult.lane?.run_id) { + throw new Error(t("errors.noRunIdReturned")); + } + + try { + const fetched = await api.run.get(laneStartResult.lane.run_id); + setHandle(fetched); + refreshList(); + } catch { + try { + await attachToRun(laneStartResult.lane.run_id); + refreshList(); + } catch (fallbackErr: unknown) { + const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown"; + throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg })); + } + } + } catch (err: unknown) { + const m = err instanceof Error ? err.message : "unknown"; + setError(t("errors.startFailed", { message: m })); + } finally { + setBusy(null); + } + }, + [busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList] + ); + + const onResumeFromHistory = useCallback( + async (item: DashboardRunHistoryItem) => { + if (!item.session_id) return; + if (busy) return; + setBusy("start"); + setError(null); + try { + let fetched: RunHandle; + + if (item.cwd) { + const effectiveCwd = item.cwd; + let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id; + + if (!targetLaneId) { + const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd }); + targetLaneId = ensureResult.lane.id; + onLaneCreated(ensureResult.lane); + } + + const laneStartResult = await api.lanes.action(targetLaneId, "start", { + prompt: "", + model: item.model || undefined, + permissionMode: item.permission_mode || undefined, + effort: item.effort || undefined, + resumeSessionId: item.session_id, + }); + + if (!laneStartResult.lane?.run_id) { + throw new Error("No run_id returned from lane start"); + } + + fetched = await api.run.get(laneStartResult.lane.run_id); + onLaneIdChange(targetLaneId); + } else { + fetched = await api.run.start({ + laneId: 0, + initialPrompt: "", + cwd: undefined, + model: item.model || undefined, + permissionMode: item.permission_mode || undefined, + effort: item.effort || undefined, + resumeSessionId: item.session_id, + }); + } + + setHandle(fetched); + setResumeSession(null); + refreshList(); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown"; + setError(t("errors.startFailed", { message: msg })); + } finally { + setBusy(null); + } + }, + [busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange] + ); + + const onViewFromHistory = useCallback( + (item: DashboardRunHistoryItem) => { + if (item.session_id) void onResumeFromHistory(item); + }, + [onResumeFromHistory] + ); + + const newRun = useCallback(() => { + setHandle(null); + setPrompt(""); + setResumeSession(null); + setError(null); + }, []); + + if (laneId === null) { + return ( +
+ +

{tLanes("splitView.emptyPane")}

+
+ ); + } + + return ( +
+ {showLaneSelector && ( + + )} + +
+
+ +
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ + {binaryStatus && !binaryStatus.found && ( +
+ + {t("binary.missing")} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {!handle ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx new file mode 100644 index 0000000..292ef37 --- /dev/null +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -0,0 +1,141 @@ +/** + * @file LaneConsolePane.test.tsx + * @description Test suite for the LaneConsolePane component + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LaneConsolePane } from "../LaneConsolePane"; +import { api } from "../../../lib/api"; +import type { Lane } from "../../../lib/types"; + +vi.mock("../TerminalView", () => ({ + TerminalView: ({ runId }: { runId: string }) => ( +
+ ), +})); + +vi.mock("../../../lib/api", () => ({ + api: { + lanes: { + ensure: vi.fn(), + action: vi.fn(), + list: vi.fn(), + }, + run: { + list: vi.fn().mockResolvedValue({ items: [] }), + history: vi.fn().mockResolvedValue({ items: [] }), + get: vi.fn(), + start: vi.fn(), + }, + }, + RUN_MODEL_CHOICES: [], + RUN_EFFORT_CHOICES: [], +})); + +const LANE: Lane = { + id: 1, + title: "demo", + cwd: "/workspace/a", + 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" as Lane["liveness"], + detected_stage: null, + detected_signal: null, + slot: null, + ports: {}, + active_feature_id: null, +}; + +function baseProps() { + return { + lanes: [LANE], + laneId: 1, + showLaneSelector: false, + onLaneIdChange: vi.fn(), + onLaneCreated: vi.fn(), + binaryStatus: { found: true, path: "/usr/local/bin/claude" }, + cwdSuggestions: [], + activeRuns: { items: [] }, + wsConnected: true, + }; +} + +describe("LaneConsolePane", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts a run through /api/lanes//start, not /api/run/start", async () => { + (api.lanes.action as ReturnType).mockResolvedValue({ + lane: { ...LANE, run_id: "run-1" }, + }); + (api.run.get as ReturnType).mockResolvedValue({ + id: "run-1", + laneId: 1, + status: "running", + cwd: "/workspace/a", + model: null, + permissionMode: null, + effort: null, + resumeSessionId: null, + sessionId: null, + startedAt: null, + promptPreview: null, + }); + + render(); + + // Set cwd and prompt + const cwdInput = screen.getByPlaceholderText(/type to search/i); + fireEvent.change(cwdInput, { target: { value: "/workspace/a" } }); + + const promptTextarea = screen.getByPlaceholderText(/ask claude/i); + fireEvent.change(promptTextarea, { target: { value: "test prompt" } }); + + // Find and click the Run button (the main start button in RunSetup) + fireEvent.click(screen.getByRole("button", { name: /^run$/i })); + + await waitFor(() => + expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object)) + ); + expect(api.run.start).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1") + ); + }); + + it("shows a lane dropdown only when showLaneSelector is true", () => { + const { rerender } = render(); + expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument(); + }); + + it("renders an empty placeholder with just a picker when laneId is null", () => { + render(); + expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); + expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); + expect(screen.queryByTestId("console-body")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 81f3019..2204591 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -120,6 +120,9 @@ "features.archived": "archived", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.", "proof.ticketReport": "Task report", + "splitView.emptyPane": "No lane selected for this pane.", + "splitView.paneLaneLabel": "Pane lane selector", + "splitView.pickLane": "Pick a lane", "statusDead": "DEAD", "title": "Lanes", "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message" diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 1e8fb74..230acdf 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -120,6 +120,9 @@ "features.archived": "đã lưu trữ", "features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.", "proof.ticketReport": "Báo cáo nhiệm vụ", + "splitView.emptyPane": "Chưa chọn lane cho ô này.", + "splitView.paneLaneLabel": "Bộ chọn lane cho ô", + "splitView.pickLane": "Chọn lane", "statusDead": "ĐÃ CHẾT", "title": "Làn đường", "tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"