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.
This commit is contained in:
2026-08-18 11:14:58 +07:00
parent c25008ab19
commit 37adf983e3
3 changed files with 67 additions and 1 deletions
+28 -1
View File
@@ -8,10 +8,15 @@
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not * `activeRuns`, and `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical * lane-specific, and fetching them per pane would mean N redundant identical
* requests for an N-pane layout. * 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ĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react"; import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -101,6 +106,28 @@ export function LaneConsolePane({
} }
}, [laneId]); }, [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( const attachToRun = useCallback(
async (id: string) => { async (id: string) => {
if (busy) return; if (busy) return;
@@ -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(<LaneConsolePane {...props} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1")
);
rerender(<LaneConsolePane {...props} laneId={2} />);
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(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
it("shows a lane dropdown only when showLaneSelector is true", () => { it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />); const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
+2
View File
@@ -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=<n>`. Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
**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-<id>` 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 ### 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: The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first: