From 37adf983e31604e3bfc938d87dcf9882767c826b Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 18 Aug 2026 11:14:58 +0700 Subject: [PATCH] fix(workspace): bind each console pane to the lane it shows LaneConsolePane kept handle/cwd/prompt/runHistory in local state that was never reset when the laneId prop changed, so selecting another lane swapped the header and detail panel while the terminal stayed attached to the previous lane's tmux session. Reset the pane on lane switch and re-attach immediately to the new lane's live run from GET /api/run when it has one, falling back to that lane's setup form when it does not. `lanes`/`activeRuns` are read through a ref so the page's 5s poll cannot wipe a half-typed prompt. --- client/src/components/run/LaneConsolePane.tsx | 29 ++++++++++++++- .../run/__tests__/LaneConsolePane.test.tsx | 37 +++++++++++++++++++ docs/LANES.md | 2 + 3 files changed, 67 insertions(+), 1 deletion(-) 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: