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:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,8 +5064,12 @@ 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 "
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
@@ -5424,6 +5428,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user