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
+6 -5
View File
@@ -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<LaneGitFacts>({ 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<LaneRuntime>({ 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 */