diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx
index f1c9ecd..a301bb5 100644
--- a/client/src/components/run/LaneConsolePane.tsx
+++ b/client/src/components/run/LaneConsolePane.tsx
@@ -118,16 +118,27 @@ export function LaneConsolePane({
// 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;
+ const { activeRuns: runs } = 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]);
+ // The cwd tracks the lane's own folder separately, keyed on the resolved
+ // path rather than on `laneId` alone: the pane can mount before the lane
+ // list has loaded (split view restores its pane lanes from localStorage),
+ // and `laneId` never changes afterwards, so a laneId-only effect would leave
+ // the console pointing at the default directory. RunSetup submits this
+ // string verbatim, so a stale one starts the run in the wrong folder.
+ const laneCwd = currentLane?.cwd ?? null;
+ useEffect(() => {
+ if (laneCwd) setCwd(laneCwd);
+ else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
+ }, [laneId, laneCwd]);
+
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 328cbb2..f32a741 100644
--- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx
+++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx
@@ -161,6 +161,19 @@ describe("LaneConsolePane", () => {
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
+ it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
+ // Split view restores its pane lanes from localStorage, so a pane can
+ // render with a laneId before GET /api/lanes has answered. laneId never
+ // changes afterwards — only the resolved lane does.
+ const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
+ const { rerender } = render();
+ const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
+ expect(cwdInput.value).toBe("/home/tester");
+
+ rerender();
+ await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
+ });
+
it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
diff --git a/client/src/pages/__tests__/Workspace.laneCwd.test.tsx b/client/src/pages/__tests__/Workspace.laneCwd.test.tsx
new file mode 100644
index 0000000..e8a3f9d
--- /dev/null
+++ b/client/src/pages/__tests__/Workspace.laneCwd.test.tsx
@@ -0,0 +1,156 @@
+/**
+ * @file Workspace.laneCwd.test.tsx
+ * @description The console's working directory must be the selected lane's own
+ * `cwd` — on first paint (the page auto-selects the first lane) and after every
+ * lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
+ * the run would be started in the previously selected lane's folder.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, act, screen, fireEvent } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import i18n from "i18next";
+
+// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
+// fixtures it reads have to be lifted with it.
+const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
+ const laneStub = (id: number, cwd: string) => ({
+ id,
+ title: `lane-${id}`,
+ cwd,
+ 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",
+ detected_stage: null,
+ detected_signal: null,
+ slot: null,
+ ports: {},
+ active_feature_id: null,
+ });
+ return {
+ HOME: { kind: "home", path: "/Users/tester", label: "Home" },
+ LANE_A: laneStub(1, "/workspace/alpha"),
+ LANE_B: laneStub(2, "/workspace/beta"),
+ };
+});
+
+vi.mock("../../lib/api", async (importOriginal) => {
+ const actual = await importOriginal>();
+ const r = (value: unknown) => vi.fn().mockResolvedValue(value);
+ return {
+ ...actual,
+ api: {
+ run: {
+ list: r({ items: [] }),
+ history: r({ items: [] }),
+ binary: r({ found: true, path: "/usr/bin/claude" }),
+ cwds: r({ items: [HOME] }),
+ files: r({ items: [] }),
+ start: r({ id: "run-1", status: "running" }),
+ get: r({ id: "run-1", status: "running" }),
+ },
+ lanes: {
+ list: r({
+ lanes: [LANE_A, LANE_B],
+ counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
+ }),
+ pipelines: r({ pipelines: [] }),
+ git: r({ available: false }),
+ runtime: r({ up: false, ports: {}, lastError: null }),
+ features: { list: r({ features: [] }), show: r({ feature: null }) },
+ proof: { list: r({ features: [] }), imageUrl: () => "" },
+ },
+ sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
+ },
+ };
+});
+
+vi.mock("../../lib/eventBus", () => ({
+ eventBus: {
+ subscribe: () => () => {},
+ publish: () => {},
+ onConnection: () => () => {},
+ connected: true,
+ setConnected: () => {},
+ },
+}));
+
+import { Workspace } from "../Workspace";
+
+class ObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords() {
+ return [];
+ }
+}
+globalThis.ResizeObserver =
+ globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
+
+async function settle() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+function cwdInput(): HTMLInputElement {
+ return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
+}
+
+beforeEach(() => {
+ i18n.changeLanguage("en");
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("Workspace — the console cwd follows the selected lane", () => {
+ it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
+ render(
+
+
+
+ );
+ await settle();
+ expect(cwdInput().value).toBe(LANE_A.cwd);
+ });
+
+ it("swaps the cwd when another lane is selected in the strip", async () => {
+ render(
+
+
+
+ );
+ await settle();
+
+ fireEvent.click(screen.getByTestId("lane-tile-2"));
+ await settle();
+ expect(cwdInput().value).toBe(LANE_B.cwd);
+
+ fireEvent.click(screen.getByTestId("lane-tile-1"));
+ await settle();
+ expect(cwdInput().value).toBe(LANE_A.cwd);
+ });
+});
diff --git a/docs/LANES.md b/docs/LANES.md
index 30d9556..91fb0fb 100644
--- a/docs/LANES.md
+++ b/docs/LANES.md
@@ -317,6 +317,8 @@ 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.
+The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
+
### 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: