diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 0d4ee44..f1c9ecd 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -8,10 +8,15 @@ * `activeRuns`, and `externalSessions` 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. + * + * That state is bound to the lane the pane currently shows: switching `laneId` + * swaps the whole pane over to the new lane — its cwd, its history, and its + * live tmux session — instead of leaving the previous lane's terminal on + * screen under a new lane's header. * @author Nguyễn Ngọc Trí Vĩ */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Play, AlertCircle } from "lucide-react"; import { api } from "../../lib/api"; @@ -101,6 +106,28 @@ export function LaneConsolePane({ } }, [laneId]); + // `lanes` and `activeRuns` are re-fetched every few seconds by the page, so + // reading them through a ref keeps the lane-switch effect below off their + // identity — a background poll must not wipe a half-typed prompt. + const latest = useRef({ lanes, activeRuns, defaultCwd }); + latest.current = { lanes, activeRuns, defaultCwd }; + + // A pane's prompt, cwd, history and terminal all belong to the lane it + // shows, so switching lanes has to swap every one of them. Re-attach right + // away when the new lane already has a live run: each lane sticks to its own + // tmux session, and the switch should land on that session rather than on an + // empty setup form the user then has to Start out of. + useEffect(() => { + const { lanes: knownLanes, activeRuns: runs, defaultCwd: fallbackCwd } = latest.current; + setPrompt(""); + setResumeSession(null); + setError(null); + setBusy(null); + setCwd(knownLanes.find((l) => l.id === laneId)?.cwd ?? fallbackCwd ?? ""); + setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null); + refreshList(); + }, [laneId, refreshList]); + const attachToRun = useCallback( async (id: string) => { if (busy) return; diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index f2da041..328cbb2 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -124,6 +124,43 @@ describe("LaneConsolePane", () => { ); }); + 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, + }); + const props = { + ...baseProps(), + lanes: [LANE, LANE2], + activeRuns: { items: [run("ccam-lane-1", 1), run("ccam-lane-2", 2)] }, + }; + + const { rerender } = render(); + await waitFor(() => + expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1") + ); + + rerender(); + await waitFor(() => + expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-2") + ); + + // A lane with no live run falls back to its setup form, not the previous + // lane's terminal. + rerender(); + await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); + }); + it("shows a lane dropdown only when showLaneSelector is true", () => { const { rerender } = render(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); diff --git a/docs/LANES.md b/docs/LANES.md index bc9cb42..30d9556 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -315,6 +315,8 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce Run history is per lane, queryable via `GET /api/run/history?laneId=`. +**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `ccam-lane-` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over. + ### Active runs list The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first: