diff --git a/CLAUDE.md b/CLAUDE.md index 15bcf1a9..5957f725 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # 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 - 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 ` 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. - **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. - **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/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. diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 826cb31f..59806acf 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; +import { keepIfSame } from "../../lib/keepIfSame"; import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types"; /** 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 .git(laneId) .then((f) => { - if (alive) setFacts(f); + if (alive) setFacts(keepIfSame(f)); }) .catch(() => { // 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. - if (alive) setFacts({ available: false }); + if (alive) setFacts(keepIfSame({ available: false })); }); }; read(); @@ -85,10 +86,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null api.lanes .runtime(laneId) .then((r) => { - if (alive) setRuntime(r); + if (alive) setRuntime(keepIfSame(r)); }) .catch(() => { - if (alive) setRuntime({ available: false }); + if (alive) setRuntime(keepIfSame({ available: false })); }); }; read(); @@ -116,7 +117,7 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] { api.locks .list() .then((data) => { - if (alive) setLocks(data.locks.filter((l) => l.holder === holder)); + if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder))); }) .catch(() => { /* fails silently, same contract as the git/runtime pollers */ diff --git a/client/src/lib/__tests__/keepIfSame.test.ts b/client/src/lib/__tests__/keepIfSame.test.ts new file mode 100644 index 00000000..d4f0e531 --- /dev/null +++ b/client/src/lib/__tests__/keepIfSame.test.ts @@ -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ĩ + */ + +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(); + }); +}); diff --git a/client/src/lib/keepIfSame.ts b/client/src/lib/keepIfSame.ts new file mode 100644 index 00000000..58f85a4e --- /dev/null +++ b/client/src/lib/keepIfSame.ts @@ -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ĩ + */ + +/** + * 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(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); +} diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 3cb521ab..e90395ae 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -55,6 +55,7 @@ import type { LaneTool, } from "../lib/types"; import { eventBus } from "../lib/eventBus"; +import { keepIfSame } from "../lib/keepIfSame"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard"; @@ -240,8 +241,8 @@ export function Workspace() { const refreshLanes = useCallback(async () => { try { const r = await api.lanes.list(); - setLanes(r.lanes); - setCounts(r.counts); + setLanes(keepIfSame(r.lanes)); + setCounts(keepIfSame(r.counts)); setSelectedLaneId((cur) => cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null) ); @@ -298,6 +299,10 @@ export function Workspace() { void refreshLanes(); 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]; next[i] = lane; return next; @@ -332,14 +337,17 @@ export function Workspace() { }); }, [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(() => { api.run .list() - .then(setActiveRuns) + .then((r) => setActiveRuns(keepIfSame(r))) .catch(() => undefined); api.sessions .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); }, []); @@ -556,12 +564,19 @@ export function Workspace() { {/* The selected lane's pipeline — the thing you actually come to this - page to read, beside the list rather than stacked under it. */} - {currentLane && ( -
+ page to read, beside the list rather than stacked under it. + 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. */} +
+ {currentLane && (
@@ -594,171 +609,151 @@ export function Workspace() {
- {showInfo && ( -
-
- - {/* `stage` defaults to the DB sentinel "idle" until the driving + )} + {currentLane && showInfo && ( +
+
+ + {/* `stage` defaults to the DB sentinel "idle" until the driving session ever calls `ccam stage` — that string collides with `status`'s own "idle"/"running" vocabulary, so a lane that is actively running but has never declared a stage read as if it were sitting idle. Show a distinct label instead of the raw sentinel whenever it doesn't match any node this pipeline actually has. */} - - {currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage) - ? currentLane.stage - : tLanes("stageUndeclared")} - - {currentLane.detected_stage && ( - - {tLanes("autoStage", { stage: currentLane.detected_stage })} - - )} - {features.length > 0 && ( - - )} - -
-
- handleLaneAction(currentLane.id, a, b)} - childWorktrees={lanes.filter( - (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id - )} - onSelectLane={setSelectedLaneId} - /> -
-
- - {viewedFeature && ( -

- {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} -

- )} -
- {proofFeature && - (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( -
- {proofFeature.ticket_report && ( - - {tLanes("proof.ticketReport")} - - )} - {Object.entries(proofFeature.groups).map(([group, images]) => ( -
- - {group} · {images.length} - -
- {images.slice(0, 8).map((img) => ( - {img} - ))} - {images.length > 8 && ( - +{images.length - 8} - )} -
-
- ))} -
- )} + {tLanes("autoStage", { stage: currentLane.detected_stage })} + + )} + {features.length > 0 && ( + + )} +
- )} -
- - setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) - } - onLaneIdChange={setSelectedLaneId} - showToggle={false} - /> +
+ handleLaneAction(currentLane.id, a, b)} + childWorktrees={lanes.filter( + (l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id + )} + onSelectLane={setSelectedLaneId} + /> +
+
+ + {viewedFeature && ( +

+ {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} +

+ )} +
+ {proofFeature && + (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( +
+ {proofFeature.ticket_report && ( + + {tLanes("proof.ticketReport")} + + )} + {Object.entries(proofFeature.groups).map(([group, images]) => ( +
+ + {group} · {images.length} + +
+ {images.slice(0, 8).map((img) => ( + {img} + ))} + {images.length > 8 && ( + +{images.length - 8} + )} +
+
+ ))} +
+ )}
-
- )} - - {!currentLane && ( -
+ )} +
+ {/* `showToggle`: the lane header carries its own split toggle; with + no lane there is no header, so the console shows one instead. */} (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane])) } onLaneIdChange={setSelectedLaneId} + showToggle={!currentLane} />
- )} +
{currentLane && removeConfirmOpen && ( diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx index 201dc841..0db438cc 100644 --- a/client/src/pages/__tests__/Workspace.test.tsx +++ b/client/src/pages/__tests__/Workspace.test.tsx @@ -584,6 +584,19 @@ describe("Workspace — the console is its own section", () => { const body = screen.getByTestId("console-body"); 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", () => { diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 68512ce3..2262c0ca 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -5064,192 +5064,283 @@ exports[`screen snapshots > Run 1`] = `

-
- - - -
-
-
-
- - - -
-
-
-

- Run Claude -

- - - Live - -
-

- Spin up a Claude session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions. -

-
- -
-
+ 1 pane + + + +
+
+
- - + +
-
-
- -