fix(workspace): stop the console remounting, and the idle polling re-renders

The Workspace page rendered `ConsoleArea` from two sites, picked by
`currentLane`. Every flip of that value unmounted the whole console
subtree and mounted it again at the other site: the terminal died, the
run history went with it, and `LaneConsolePane`'s lane-switch effect
wiped a half-typed prompt. It flipped on every page load (lanes arrive,
`refreshLanes` selects `lanes[0]`), on a lane being removed, and on any
refetch that re-picked the selection.

`lane-detail` is now always rendered and holds the only `ConsoleArea`;
the header and info block are the conditional parts. Same rule the
RunConsole already followed one level down — collapse with CSS, never by
unmounting — now enforced above it too.

Second half: every poller fed `setState` a brand-new object graph each
tick, re-rendering the lane strip, the card and the console area several
times a minute on a completely idle dashboard. `keepIfSame` keeps the
previous reference when the payload is value-identical, applied to the
5 s run/session poll, the lane list and counts, the `lane_update` WS
branch, and LaneCard's git/runtime/locks pollers.

Verified: npm run test:client (44 files / 454 tests), tsc --noEmit
clean, check-headers.sh clean. The Run screen snapshot was regenerated —
the diff is the added `<section>` wrapper and one indent level, no
content change.
This commit is contained in:
2026-09-04 10:48:22 +07:00
parent e109d2804c
commit 965c62c5cf
7 changed files with 547 additions and 462 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: 8a812a1 (2026-09-03) + codex auto-advance._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. _Last synced: e109d28 (2026-09-04) + Workspace single console render site._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -27,7 +27,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it. - **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life. - **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. - **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. - **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them. - **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above. - **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index. - **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
+6 -5
View File
@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types"; import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose: /** How often a mounted card re-reads its working-copy facts. Slow on purpose:
@@ -49,12 +50,12 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
api.lanes api.lanes
.git(laneId) .git(laneId)
.then((f) => { .then((f) => {
if (alive) setFacts(f); if (alive) setFacts(keepIfSame(f));
}) })
.catch(() => { .catch(() => {
// Silent: a card that cannot read git shows the rest of itself. An // Silent: a card that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip. // error banner here would fire on every lane on every server blip.
if (alive) setFacts({ available: false }); if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
}); });
}; };
read(); read();
@@ -85,10 +86,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
api.lanes api.lanes
.runtime(laneId) .runtime(laneId)
.then((r) => { .then((r) => {
if (alive) setRuntime(r); if (alive) setRuntime(keepIfSame(r));
}) })
.catch(() => { .catch(() => {
if (alive) setRuntime({ available: false }); if (alive) setRuntime(keepIfSame<LaneRuntime>({ available: false }));
}); });
}; };
read(); read();
@@ -116,7 +117,7 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
api.locks api.locks
.list() .list()
.then((data) => { .then((data) => {
if (alive) setLocks(data.locks.filter((l) => l.holder === holder)); if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder)));
}) })
.catch(() => { .catch(() => {
/* fails silently, same contract as the git/runtime pollers */ /* fails silently, same contract as the git/runtime pollers */
@@ -0,0 +1,36 @@
/**
* @file keepIfSame.test.ts
* @description Covers the identity contract `keepIfSame` exists for: a polled
* payload that is value-identical must keep the PREVIOUS reference (so React
* bails out of the re-render), and any real change must yield the new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { keepIfSame } from "../keepIfSame";
describe("keepIfSame", () => {
it("keeps the previous reference when the fetched payload is value-identical", () => {
const prev = [{ id: 1, stage: "implement" }];
const fetched = [{ id: 1, stage: "implement" }];
expect(keepIfSame(fetched)(prev)).toBe(prev);
});
it("yields the new value when anything actually changed", () => {
const prev = [{ id: 1, stage: "implement" }];
const fetched = [{ id: 1, stage: "review" }];
expect(keepIfSame(fetched)(prev)).toBe(fetched);
});
it("yields the new value out of the null a poller starts from", () => {
const fetched = { available: false };
expect(keepIfSame(fetched)(null)).toBe(fetched);
});
it("keeps null when the payload is still null", () => {
expect(keepIfSame(null)(null)).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* @file keepIfSame.ts
* @description State-updater helper for polled data: keeps the previous value's
* identity when the freshly fetched payload is value-identical, so a background
* poll that found nothing new does not re-render the tree it feeds.
*
* Every poller in this app (`api.lanes.list`, `api.run.list`, `api.lanes.git`,
* …) returns a brand-new object graph on each tick. Passing that straight into
* `setState` re-renders every consumer several times a minute even when the
* dashboard is completely idle — visible as the whole page "refreshing".
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* Wraps a freshly fetched value as a functional state update that returns the
* previous state unchanged when the two are value-equal.
*
* Usage: `setLanes(keepIfSame(r.lanes))` instead of `setLanes(r.lanes)`.
*
* `prev` is typed wider than `T` so the same helper works for the `T | null`
* states the pollers start out in; the cast is sound because it only happens on
* the branch where `prev` serializes identically to `next`.
*
* @param next The value just fetched.
* @returns An updater for `setState`, yielding `prev` when nothing changed.
*/
export function keepIfSame<T>(next: T): (prev: T | null | undefined) => T {
// ponytail: JSON.stringify comparison — fine for the payloads this app polls
// (lane lists, run lists, a git-facts object: tens of small records, key order
// is stable because it comes from the same serializer every time). Swap for a
// structural deep-equal if a poller ever returns something big or key-unstable.
return (prev) => (JSON.stringify(prev) === JSON.stringify(next) ? (prev as T) : next);
}
+33 -37
View File
@@ -55,6 +55,7 @@ import type {
LaneTool, LaneTool,
} from "../lib/types"; } from "../lib/types";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard"; import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
@@ -240,8 +241,8 @@ export function Workspace() {
const refreshLanes = useCallback(async () => { const refreshLanes = useCallback(async () => {
try { try {
const r = await api.lanes.list(); const r = await api.lanes.list();
setLanes(r.lanes); setLanes(keepIfSame(r.lanes));
setCounts(r.counts); setCounts(keepIfSame(r.counts));
setSelectedLaneId((cur) => setSelectedLaneId((cur) =>
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null) cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
); );
@@ -298,6 +299,10 @@ export function Workspace() {
void refreshLanes(); void refreshLanes();
return cur; return cur;
} }
// The server re-broadcasts a lane on hook activity that often changed
// nothing this page renders; replacing the array anyway would re-render
// the strip and the console on every such event.
if (JSON.stringify(cur[i]) === JSON.stringify(lane)) return cur;
const next = [...cur]; const next = [...cur];
next[i] = lane; next[i] = lane;
return next; return next;
@@ -332,14 +337,17 @@ export function Workspace() {
}); });
}, [lanes]); }, [lanes]);
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
const refreshList = useCallback(() => { const refreshList = useCallback(() => {
api.run api.run
.list() .list()
.then(setActiveRuns) .then((r) => setActiveRuns(keepIfSame(r)))
.catch(() => undefined); .catch(() => undefined);
api.sessions api.sessions
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true }) .list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
.then((r) => setExternalSessions(r.sessions)) .then((r) => setExternalSessions(keepIfSame(r.sessions)))
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
@@ -556,12 +564,19 @@ export function Workspace() {
</div> </div>
{/* The selected lane's pipeline — the thing you actually come to this {/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it. */} page to read, beside the list rather than stacked under it.
{currentLane && ( The section itself is ALWAYS rendered, even with no lane selected:
`ConsoleArea` lives inside it, and a conditional wrapper would give
the console two render sites, so every flip of `currentLane` (lanes
arriving on mount, a lane removed, a refetch re-picking the
selection) unmounted the terminal and wiped a half-typed prompt.
Same rule as RunConsole's own collapse — hide with CSS, never by
unmounting. */}
<section <section
data-testid="lane-detail" data-testid="lane-detail"
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4" className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
> >
{currentLane && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary"> <span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
@@ -594,7 +609,8 @@ export function Workspace() {
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} /> <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
</div> </div>
</div> </div>
{showInfo && ( )}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto"> <div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3 flex flex-wrap items-baseline gap-2"> <div className="mb-3 flex flex-wrap items-baseline gap-2">
<select <select
@@ -679,9 +695,7 @@ export function Workspace() {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<PipelineMap <PipelineMap
nodes={ nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal} detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/> />
{viewedFeature && ( {viewedFeature && (
@@ -733,7 +747,13 @@ export function Workspace() {
)} )}
</div> </div>
)} )}
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3"> <div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea <ConsoleArea
lanes={lanes} lanes={lanes}
selectedLaneId={selectedLaneId} selectedLaneId={selectedLaneId}
@@ -751,34 +771,10 @@ export function Workspace() {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
} }
onLaneIdChange={setSelectedLaneId} onLaneIdChange={setSelectedLaneId}
showToggle={false} showToggle={!currentLane}
/> />
</div> </div>
</section> </section>
)}
{!currentLane && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
/>
</div>
)}
</div> </div>
{currentLane && removeConfirmOpen && ( {currentLane && removeConfirmOpen && (
@@ -584,6 +584,19 @@ describe("Workspace — the console is its own section", () => {
const body = screen.getByTestId("console-body"); const body = screen.getByTestId("console-body");
expect(detail.contains(body)).toBe(true); expect(detail.contains(body)).toBe(true);
}); });
// A SECOND render site for the console is what let `currentLane` flipping
// (lanes arriving on mount, the selected lane removed, a refetch re-picking
// the selection) unmount the terminal and wipe a half-typed prompt. It must
// sit inside `lane-detail` in BOTH states, so React never has to move it.
it("keeps the console at one render site whether or not a lane is selected", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
expect(detail.contains(screen.getByTestId("console-body"))).toBe(true);
});
}); });
describe("Workspace — proof gallery", () => { describe("Workspace — proof gallery", () => {
@@ -5064,6 +5064,10 @@ exports[`screen snapshots > Run 1`] = `
</code> </code>
</p> </p>
</div> </div>
<section
class="flex min-h-0 min-w-0 flex-1 flex-col "
data-testid="lane-detail"
>
<div <div
class="flex min-h-0 flex-1 flex-col gap-2 " class="flex min-h-0 flex-1 flex-col gap-2 "
> >
@@ -5424,6 +5428,7 @@ exports[`screen snapshots > Run 1`] = `
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
</div> </div>
</div> </div>