Add Mission Control: a live grid of every agent pane - #484
Conversation
Running several agents at once means switching sessions to find out which one is waiting for an answer. This adds a top-level view that shows them all at once, grouped by project, status or agent. - Grid of tiles, one per agent pane, with status accent, branch and age - Cheap ANSI-stripped snapshots by default; a tile becomes a real xterm on hover, or all of them with "All live" - Type into a focused tile without leaving the grid, optionally expanding it to the agent's full screen - Fold projects away, jump to the next blocked agent (N), move with the arrow keys, close a pane - Extracts the screen-text selection shared with the RunPane CLI into services/panels/terminalScreenText.ts The viewer renders at exactly the PTY's dimensions and never answers the agent's terminal queries — both are load-bearing, and both are documented where they are enforced. Tests: fleetGrouping (14) covers grouping, ordering and pane labelling.
Mission Control is the product name for the grid. The rename covers the user-facing strings, the sidebar entries in both the rail and the tree, the `activeView` value, the two IPC channels, the CSS hook, and the file and symbol names, so a search for the feature finds all of it under one word. Behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
…weep The branch was written before main hardened its lint gates, so `pnpm lint` failed on 20 blocking anti-slop errors and 6 Knip findings, every one in a new file. Fixing them properly rather than by suppression: - Dictionaries keep their inference and validate with `satisfies`, as the repo's other maps do. - Type assertions that remain carry the `SAFETY:` invariant the rule asks for. - The terminal-output and `terminal:getState` payloads are decoded with `boundary`/`decodeBoundary`, matching `useRemoteTerminal`, and the persisted view options are decoded the same way. A stale `density` previously reached layout maths as a `NaN` tile height, and a non-iterable stored group list threw during render. - The two `export default`s, the dead label re-export, and the exported channel array are gone; all 13 other IPC domains keep that array local. It also fixes a defect the gates cannot see: the stale-viewer sweep matched nothing. The main process swept the prefix `mission-control:`, the renderer minted `missionControl:<uuid>`, and `visibilityViewerMatchesPrefix` appends its own separator, so a prefix ending in a colon can never match. Both processes now read `MISSION_CONTROL_VIEWER_PREFIX` from `shared/types/missionControl.ts`, and a test registers a viewer under that constant and asserts the sweep clears it. `terminalScreenText.ts` gains the tests its logic had while it lived in `runpane.ts`, xterm's helper textarea leaves the tab order while its tile is `aria-hidden`, and the `AGENTS.md` navigation invariant now describes the `ActiveView` union this branch introduced. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
|
React Doctor found 6 new issues in 2 files · 1 error & 5 warnings · score 70 / 100 (Needs work) · 0 fixed · vs Errors
5 warnings
Reviewed by React Doctor for commit |
Reuse and de-duplication, with no change to what the view does. Shared instead of re-implemented: - `formatTimeAgo` moves to `utils/timestampUtils.ts`, replacing three copies of the same thresholds and strings. - `terminalOutputByteLength` is shared by the two terminal hooks that ack PTY bytes, and encodes once at module scope rather than per chunk. - `useBlockedAgentCount` joins the other agent-status selectors, so the two sidebars stop computing the Mission Control badge separately. - Next-blocked cycling uses `cycleIndex`, and the AGENTS.md secondary-terminal invariant now describes fitting by font size, which is what the code does. Simplified in place: - One `usePersistedOption` hook replaces five `useState` plus five write effects, and writes on set, so opening the view stops rewriting all five values it just read. - A `SegmentedControl` and a `StatusCountPill` replace two and four copies of the same markup. - One definition of "visible": the poll batch and the keyboard navigation share it, and the group-collapse guard that could never fire is gone. - The `focusedTile` memo collapses into the effect that was its only reader. - Frozen display order ranks from a map rather than `indexOf` per comparison, one collator serves every name comparison, and the per-tile `ref` callback is stable so a poll no longer detaches and reattaches every tile element. - Snapshot polling awaits every emulator's idle at once, since that part is a wait rather than work; the extraction that follows stays sequential. Payload fields nothing reads are gone: the snapshot's `lineCount` and `isLive`, the agent's `sessionArchived`, and the `missing` list, which the client already handles by replacing its whole snapshot map each poll. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
84ab592 to
2a27ef1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84ab5929e6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Six review findings, none of which changed what the view is for. **A stopped agent's tile showed a mangled transcript.** `alternateScreenBuffer` is the raw PTY byte stream, and a full-screen TUI paints it with absolute cursor positioning, so stripping the escapes concatenated every word. The emulator already knows the laid-out answer and keeps it past dispose, so panels now persist `screenText`, exit persists state before teardown (an agent that finishes on its own never went through `destroyTerminal`), and readers prefer it. `runpane panels screen` gets the same fix, since both go through `terminalScreenText.ts`. RunPane decodes `customState` against an allow-list, so the field is declared there too: omitting it silently dropped the text and sent every stopped panel back to the byte log, which a test now catches. **Tiles rendered in a hardcoded Menlo stack** while the panel they mirror used the configured font. Worse, the character-width probe that chooses a tile's font size measured Menlo, so the column-fit maths ran on the wrong metrics. `buildTerminalFontFamily`, `getMinimumContrastRatio` and the default family move out of `TerminalPanel` into `utils/terminalTheme.ts`, tiles read the same config, and the probe caches per family. **Focusing a tile rebuilt and re-hydrated its terminal**, contradicting the comment that said it never did: `interactive` fed the row count, and rows are what the terminal effect rebuilds on. Rows are now always the PTY's, so taking the keyboard flips options on the terminal already there. **The view kept polling while the window sat behind another app.** `document.visibilityState` stays "visible" for an unfocused window, so the guard never fired. `useWindowActive` gates on focus as well, the interval stops rather than skipping ticks, and hover promotions are dropped so no tile streams PTY output at a screen nobody is watching. **The header ran off the edge on a narrow window.** A `fieldset` holds a `min-inline-size: min-content` floor, so the control groups refused to shrink; they are labelled `role="group"` containers now, and the header shrinks and wraps. The grid caps its column count at what the width can carry, so "4x" on a narrow view lays out fewer, readable tiles instead of 66px slivers. **Refs were mutated during render.** They use the repo's `useCommittedRef`, and the sticky-dimensions ref is read during render but written in an effect. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
**Flow control gets a single designated consumer.** `pendingBytes` is one counter per PTY, so every viewer that acks the same broadcast chunk credits it again: two viewers drain it at twice the rate the PTY debited it, the high watermark is never reached, and the PTY is never paused. VS Code's terminal takes the same shape, one `_unacknowledgedCharCount` per process with a single consumer, and never puts two xterms on one PTY. Acks now carry a viewer id and main designates one consumer per panel from the viewers it already tracks: a real panel or remote runtime when there is one, a preview tile only when it is the only viewer, since otherwise nobody would ack and the PTY would stay paused. Callers that send no viewer id are still credited, because every ack path predates them. This also closes the same double-ack between a panel and the Remote PWA, which predates Mission Control. **Closing Pane Chat killed it and claimed success.** `panels:delete` destroyed the terminal, then `deletePanel` declined the permanent panel and returned quietly, so the handler reported `success: true` while the agent was dead and the panel still there. The handler now refuses a permanent panel before any teardown, and Mission Control does not render a close action for one. **A finished agent kept a live tile.** The roster refreshed on the *set* of panel ids, and an agent that exits keeps its id and only changes state, so `isLive` never reloaded and the tile stayed interactive. The signature covers the values now. **Closing an agent in a background session wedged the sidebar badge.** `SessionView` clears agent status only for its own active session, so the deleted panel's `blocked` entry survived and kept the count up. Mission Control clears it as part of the delete it performed. **Past the 64-panel snapshot cap, a tile could still go live** with no PTY dimensions, falling back to 80x24 and wrapping every line of a differently sized TUI. Promotion now requires known dimensions, which is what the footer note already promised. Idle cost is split by signal rather than stopping dead on blur: hiding the window stops everything, losing focus drops the live tiles (the expensive part) and backs the snapshot poll off, so a grid left open on a second monitor still reads as current. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
The worst of these I introduced two commits ago, and it would have hit every user on the default path. **Terminal panels stopped acking entirely.** `terminal:setVisibility` scoped a bare viewer id to `local:<id>`, and the new `terminal:ack` passed the same id through raw, so a panel registered as `local:<uuid>` and acked as `<uuid>`. The designated-consumer comparison then failed for every ack from a visible terminal: `pendingBytes` climbed unchecked to the high watermark, the PTY paused, and the safety timer force-resumed it without clearing the counter, so a busy build would deliver roughly one chunk every five seconds. Scoping now lives in `normalizeVisibilityViewerId`, the one place both paths go through, and a test registers scoped and acks bare (it fails without the fix). My earlier test missed this because it passed already-scoped ids on both sides. **Focusing a tile with no snapshot yet swallowed every keystroke.** The click affordance and Enter gated on `isLive`, but a live terminal also needs the PTY dimensions that ride with a snapshot. One `canTakeKeyboard` predicate now backs the affordance, Enter, next-blocked, and the live branch, and the focused panel is always included in the snapshot batch so its terminal cannot be unmounted mid-typing by the 64-panel cap. **A promoted tile could show stale output.** The output subscription wrote immediately while `hydrate()` replayed the whole buffer after an IPC round trip, so anything arriving in between was clobbered by an older screen. Output is queued until the restore lands, then flushed in order. It is still acked while queued, because those bytes have left the PTY's budget either way. **One unsettled emulator froze the entire grid.** `waitForIdle` resolves only on drain or dispose, and the client holds an in-flight guard across the call, so a single panel that never settled stopped snapshots for every tile with nothing on screen to say so. The wait is bounded now, and the guard is released with the effect. **A failed close blanked the view**, because the catch set the error state that replaces the whole grid. It reports beside the grid instead. **Opening a Pane Chat tile** navigated to the sessions view for a session that is not in the sidebar. It uses `navigateToPaneChat` now. Idle cost follows `terminalPowerMode` rather than a policy of this view's own: Performance keeps the poll and the hovered tile alive while the window is behind something else, Battery Saver drops them, matching what `TerminalPanel` already does with the same setting. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
The expanded tile cut its terminal off mid-word at the right edge. `measure()` budgeted the whole wrapper for glyphs, but the rendered terminal also carries the `.xterm` padding, the `.xterm-screen` left margin, and the expanded container's inset: about 26px it never accounted for. The font it chose was therefore always slightly too large, and the trailing columns landed outside a box whose overflow is hidden. It now fits against the space the glyphs actually get. Snapshot bodies never fitted at all. They rendered at a hardcoded 11px in the generic mono token and let the tile cut whatever missed, so an unpromoted or stopped tile clipped silently at every width while a live one scaled. They now choose a font size the same way the live terminal does, against the PTY's columns or, for a stopped pane that no longer reports any, the widest line in the text. Both paths share that arithmetic in `utils/terminalFit.ts`, which is the point: a tile that fitted one way as a snapshot and another once live would visibly jump under the pointer. `clippedRight` was also lying. It compared widths, and the screen starts inside the terminal's padding, so the comparison missed the columns actually being cut; and it only ran when output arrived, so once the font hit its floor a narrowing tile clipped further with nothing to trigger a recheck, and an idle agent never triggered one at all. It compares edges now and re-runs on resize, and the snapshot path shows the same fade, so clipping is signalled in both rather than looking like a rendering fault in either. Below roughly 520px of tile width the font sits at its 9px legibility floor and the surplus columns are still clipped. That is rule 1 working as intended: a tile never resizes the agent's PTY, so the only lever is the font, and past the floor the honest move is to clip and say so rather than render text nobody can read. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
Two follow-ups from review, both the same idea: derive the limit from the space there is rather than from a constant. **Columns.** The cap was a flat 220px per tile, a number picked for header legibility that had nothing to do with showing a terminal. It is now what a conventional 80-column screen needs at the legibility floor, measured against the user's configured font, and it counts the gaps between tiles. So the same density setting means more columns on a large display and fewer on a laptop: | viewport | columns at 4x | tile | | --- | --- | --- | | 3840 | 4 | 927px | | 2560 | 4 | 607px | | 1920 | 4 | 447px | | 1440 | 2 | 664px | | 800 | 1 | 698px | A 4K display honours 4x; a narrow window steps down instead of shrinking tiles past the point where their content reads. This also cleared the last horizontal overflow: 380px through 1440px now measure zero escaping elements and zero document scroll, where 440 and 380 still had some. **The expanded tile fits vertically.** `byHeight` divided by `fontSize * lineHeight`, but xterm's `lineHeight` multiplies the font's natural line box, which is taller than the font size — about 1.5x rather than 1.2x for the default terminal font. Every row was under-counted, so the grid came out a quarter taller than the box it had been fitted to and quietly pushed the oldest rows out of view. The cell height is measured now, alongside the width that was already measured, and the row-height constants that were calibrated against the old estimate are corrected with it. Two consequences worth naming. The expanded body's ceiling now comes from the height the grid actually has, so a tall display gets a taller tile instead of stopping at 620px. And fitting both axes of a nearly square character grid inside a wide row always leaves width over, so the expanded terminal is centred rather than pinned left, where the gap read as a rendering fault. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
…eads A four-angle review of the branch. The theme is that `terminalFit.ts` exists so a tile fits identically as a snapshot and as a live terminal, and both paths had quietly stopped going through it. - The live path re-inlined `fitFontSize` character for character, so only one of the two callers used the tested helper. - The snapshot body set its line height to `fontSize * lineHeight`, the estimate that module's own comment warns against, making snapshot rows about a quarter shorter than live rows in a box budgeted for live ones. - Two row-height constants were hand-transcribed products of the same formula, which is exactly how they came to be miscalibrated twice already. `rowHeight()` is back and used in all three places. I deleted it last round because Knip called it unused; the right answer was to use it. Cheaper, same behaviour: - Building a terminal waited for the wrapper to be measured. The seed geometry is a guess and never the answer, so every hover promotion built an xterm, fetched `terminal:getState` and replayed the whole buffer, then threw all of it away a tick later. Measured: one terminal built per promotion, was two. - `boundSanitizedLines` takes the tail before stripping. Sanitizing is nine full-string passes, and asking for 16 lines of a 500KB scrollback paid for all of it, once per panel per poll. - The snapshot map reuses unchanged snapshot objects and `onOpen` is stable, so `memo` on the tile can actually hold instead of every tile reconciling every poll. - The idle-wait race clears its losing timer, which was leaving one live timer per panel per tick. - The poll key is order-insensitive: it is the set that matters, and `groups` reorders on any status change, so the interval was being torn down and restarted on ordinary churn. Correctness found on the way: - Hover handlers read the raw `liveAll` toggle rather than `liveAllActive`, so past the live-tile cap nothing was live and hover promotion was disabled too: a grid of snapshots with nothing saying why. - The delete path hand-cleared one store field. `removePanel` is what the other two delete sites call and it forgets the panel's status, activity and list entry together. - `COLUMN_CHANGE_THRESHOLD` outlived its purpose: `cols` is the PTY's own count now, not derived from the tile, so there is no jitter to absorb and the threshold only swallowed a real one-column resize, which rule 1 forbids. Deduplication the branch had left half done: `App.tsx` now uses `useWindowActive` rather than rolling the same visible-plus-focused pair by hand, so the hook is a consolidation instead of a fourth copy. One label map and one agent-name helper serve the group headings and the tiles, which had already drifted on the fallback wording. Claude-Session: https://claude.ai/code/session_01Ld3eNtZR88mjkHJCtNEgLw
Nine findings from a fresh review of #484. The four blockers first. **One responder per PTY.** Mission Control replaces the session tree, so the normal TerminalPanel is unmounted and a tile that swallowed every terminal query left nobody to answer: a Codex agent asking for the cursor position waited on a reply that never came. The rule is now one voice, not no voice. The tile holding the keyboard answers; the answer travels on a new `terminal:reply` channel, and `terminalPanelManager` drops it unless that viewer is the panel's designated one — the same designation the ack consumer already used, so a real panel still wins when there is one and two Mission Control windows cannot both speak. Replies produced while the saved buffer replays are suppressed: those queries were answered when they were first asked. **#486 reconciled.** `terminalCapabilities` is now the single place that says what a Pane terminal is: proposed APIs, the user's kitty keyboard setting, Unicode 11 widths, and the image addon on the shared limits. TerminalPanel and Mission Control tiles both load it. Live tiles render inline images deliberately — a tile is a screen someone reads a running agent in, and the addon allocates image storage only once an image arrives. Unicode 11 everywhere matters even where images do not: a renderer on Unicode 6 gives a wide emoji one cell where the PTY gave it two, and every absolute repaint after it lands a column off. **A roster that lets go.** Deleting a pane from RunPane or archiving a session removes the PTY without changing any agent's status, so the grid never reloaded and the dead pane's tile sat there saying "Waiting for output..." Mission Control now listens to panel and session lifecycle events and refreshes on them, coalesced; whatever it held by a departed panel's id — the keyboard, the live terminal, the roving cursor, an open close confirmation, the snapshot, the element — is dropped with it. Panel status entries are cleared globally too, so a stale `blocked` cannot keep the sidebar's badge lit for a pane that no longer exists. **Expanded honours its own height.** Whole-grid mode fits both axes through the shared helper, with a floor low enough to keep the promise it makes. What even that cannot fit is measured off the rendered screen rather than trusted from the arithmetic, and the tile grows by the shortfall so the grid scrolls to it instead of clipping the oldest rows behind a wrapper that says nothing. Then the rest. Tile models keep their identity when nothing on them moved, so one agent's output no longer re-renders all sixty-four tiles. Live tiles are gated on an IntersectionObserver with a grace period, and every tile's output arrives through one dispatcher keyed by panel id rather than one global listener per tile. Roster loads are request-owned, so an older list landing last cannot resurrect a dead agent. Columns are capped by the number of tiles as well as the width, so a lone agent gets the whole row. `fitFontSize` floors rather than rounds, because the largest size that fits may not be rounded up to one that does not. Merged origin/main first, which is where #486 lives.
Nine findings from a fresh review of #484. The four blockers first. **One responder per PTY.** Mission Control replaces the session tree, so the normal TerminalPanel is unmounted and a tile that swallowed every terminal query left nobody to answer: a Codex agent asking for the cursor position waited on a reply that never came. The rule is now one voice, not no voice. The tile holding the keyboard answers; the answer travels on a new `terminal:reply` channel, and `terminalPanelManager` drops it unless that viewer is the panel's designated one. That is the same designation the ack consumer already used, so a real panel still wins when there is one, and two Mission Control windows cannot both speak. Replies produced while the saved buffer replays are suppressed, since those queries were answered when they were first asked. **#486 reconciled.** `terminalCapabilities` is now the single place that says what a Pane terminal is: proposed APIs, the user's kitty keyboard setting, Unicode 11 widths, and the image addon on the shared limits. TerminalPanel and Mission Control tiles both load it. Live tiles render inline images deliberately, because a tile is a screen someone reads a running agent in and the addon allocates image storage only once an image arrives. Unicode 11 everywhere matters even where images do not: a renderer on Unicode 6 gives a wide emoji one cell where the PTY gave it two, and every absolute repaint after it lands a column off. **A roster that lets go.** Deleting a pane from RunPane or archiving a session removes the PTY without changing any agent's status, so the grid never reloaded and the dead pane's tile sat there saying "Waiting for output..." Mission Control now listens to panel and session lifecycle events and refreshes on them, coalesced. Whatever it held by a departed panel's id (the keyboard, the live terminal, the roving cursor, an open close confirmation, the snapshot, the element) is dropped with it. Panel status entries are cleared globally too, so a stale `blocked` cannot keep the sidebar's badge lit for a pane that no longer exists. **Expanded honours its own height.** Whole-grid mode fits both axes through the shared helper, with a floor low enough to keep the promise it makes. What even that cannot fit is measured off the rendered screen rather than trusted from the arithmetic, and the tile grows by the shortfall so the grid scrolls to it instead of clipping the oldest rows behind a wrapper that says nothing. Then the rest. Tile models keep their identity when nothing on them moved, so one agent's output no longer re-renders all sixty-four tiles. Live tiles are gated on an IntersectionObserver with a grace period, and every tile's output arrives through one dispatcher keyed by panel id rather than one global listener per tile. Roster loads are request-owned, so an older list landing last cannot resurrect a dead agent. Columns are capped by the number of tiles as well as by the width, so a lone agent gets the whole row. `fitFontSize` floors rather than rounds, because the largest size that fits may not be rounded up to one that does not. Merged origin/main first, which is where #486 lives.
d8237bc to
4a40012
Compare
The agent switcher test focused a radio and pressed a key as two separate driver round trips. Switching agents mounts a new Pane Chat terminal, and that terminal takes focus about fifty milliseconds after it appears, so on a loaded runner the press left the driver after the terminal had already taken the keyboard and landed there instead of on the radio. The radio never changed, and the run failed waiting for it to be checked. The same commit passed on the run before it, which is what a lost race looks like. Both presses now re-aim and press again until the choice they name holds the focus and the checkmark, which also covers the shorter window where the group ignores input while a switch is still in flight. Reproduced by delaying the press four hundred milliseconds after focusing the radio: the old shape fails with the call log CI printed, the new one passes. Claude-Session: https://claude.ai/code/session_019HUWz4ExnXV3LRcMrNe18t
parsakhaz
left a comment
There was a problem hiding this comment.
Review pass 1: correctness and security
Three Must-Fix flow-control defects were found. No security defects were found.
-
Remote terminal acknowledgements bypass designated-viewer arbitration.
frontend/src/remote/runtime/remoteRuntimeAdapter.ts:187sendsterminal:ackwithout the viewer ID, while visibility registration includes it.main/src/services/terminalPanelManager.ts:836credits unidentified acknowledgements without checking the designated viewer, andmain/src/daemon/httpApiServer.ts:785scopes only visibility calls. With desktop and Remote PWA watching one PTY, the same chunk can be credited twice, defeating the high watermark. -
Mission Control acknowledges chunks before xterm consumes them.
frontend/src/hooks/useMissionControlTerminal.ts:535credits queued pre-hydration output immediately, and the hydrated path credits immediately after schedulingterminal.write. The primary terminal correctly acknowledges from xterm's write callback atfrontend/src/components/panels/TerminalPanel.tsx:1548. A busy agent can therefore outrun the tile parser without triggering backpressure. -
Mission Control's acknowledgement unit differs from main's debit unit. Main debits
data.lengthatmain/src/services/terminalPanelManager.ts:712, but Mission Control and the Remote PWA credit UTF-8 byte length. CJK or emoji output credits more than was debited and repeatedly clamps the pending counter to zero.
Required fixes: carry and identically scope the remote viewer ID for acknowledgement; acknowledge Mission Control output only after xterm's callback, using bounded batching; use one unit on both sides and add multibyte regression coverage.
Checks: git diff --check origin/main...HEAD; pnpm typecheck; pnpm lint; 78 focused frontend tests; 132 focused main tests. All passed before fixes.
Scope remote acknowledgements to their registered viewer, credit output only after xterm consumes it, and use UTF-8 byte accounting throughout. Add regression coverage for viewer propagation, remote scoping, batching, and multibyte output.
Use one request generation counter and one derived terminal interaction predicate without changing behavior.
Preserve exact PTY dimensions above 400 columns, share acknowledgement batching between primary and secondary viewers, and remove the unread snapshot timestamp.
| * share a WebGL texture atlas, and a grid of live terminals would exhaust | ||
| * Chromium's GL contexts (see `TerminalPanel`'s header comment). | ||
| */ | ||
| export function MissionControlView() { |
There was a problem hiding this comment.
React Doctor · react-doctor/no-giant-component (warning)
Component "MissionControlView" is over 300 lines long, which is hard to read & change. Split it into a few smaller components.
Fix → Pull each section into its own component so the parent is easier to read, test, and change.
| const promoteTimerRef = useRef<number | undefined>(undefined); | ||
| const inFlightRef = useRef(false); | ||
| /** Roster loads overlap; only the newest one gets to set the roster. */ | ||
| const rosterGateRef = useRef(createRequestGate()); |
There was a problem hiding this comment.
React Doctor · react-doctor/rerender-lazy-ref-init (warning)
useRef(createRequestGate()) rebuilds this value on every render & throws it away.
Fix → Initialize the ref lazily so expensive values are not rebuilt and discarded on every render.
| const tiles: MissionControlTileModel[] = useMemo( | ||
| () => { | ||
| const next = reconcileTileModels(tilesRef.current, agents, agentStatus, snapshots); | ||
| tilesRef.current = next; |
There was a problem hiding this comment.
React Doctor · react-doctor/no-ref-current-in-render (error)
This ref is mutated during render. React can replay or discard render work, so the mutation can leak from UI that never commits.
Fix → Move ref writes into an event handler or effect. Render must stay pure because React can replay or discard it. The predictable null-guarded lazy initialization pattern remains supported.
|
|
||
| // Poll snapshots only while this view is actually on screen. A background | ||
| // poll would keep every agent's emulator warm for nothing. | ||
| useEffect(() => { |
There was a problem hiding this comment.
React Doctor · react-doctor/no-set-state-after-await-in-effect (warning)
This setter runs after await, so overlapping re-runs of the effect can resolve out of order and write stale state; gate it behind a cancellation/ignore flag or return a cleanup that cancels the work.
Fix → In a useEffect whose dependencies can change, guard any setter call that runs after an await behind a cancellation/ignore flag, or return a cleanup that cancels the async work.
| * confirmation, its last snapshot, its element — refers to something that no | ||
| * longer exists, and a close confirmation left open would fail on confirm. | ||
| */ | ||
| useEffect(() => { |
There was a problem hiding this comment.
React Doctor · react-doctor/no-effect-chain (warning)
Your screen redraws several times from a single action because one useEffect changes "liveTileId", which sets off this one.
Fix → Compute as much as possible during render (e.g. const isGameOver = round > 5) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve
| // Collapsing back to preview size drops any growth the expanded view needed, | ||
| // rather than leaving the tile tall until the rebuilt terminal measures again. | ||
| useEffect(() => { | ||
| if (!matchPtyExactly) setHeightShortfall(0); |
There was a problem hiding this comment.
React Doctor · react-doctor/no-adjust-state-on-prop-change (warning)
This effect adjusts state after a prop changes, so users briefly see the stale value.
Fix → Remove the adjustment effect by deriving values during render, resetting the component with a key, or updating related state in the event that changes the prop. Avoid tracking the previous prop in more state, which preserves the duplication. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes




What you are approving
Mission Control: a new top-level view with one tile per agent pane, so several
agents can be watched at once. @0x92 wrote the feature in
#382; the commits after theirs are a
rename and several rounds of review fixes, kept separate so their work stays
legible underneath.
terminal flow control, so it affects every terminal, not just the new grid. It
is the first item under "Defects found and fixed" below, "Flow control credited
the same bytes twice".
dropped every terminal panel's ack. A later review caught it, it is fixed, and a
test now fails without the fix. Worth a second pair of eyes precisely because it
is the one thing here that can affect someone who never opens Mission Control.
promotion, focus, collapse, grouping, density, stopped tiles, and widths from
380px to 1440px.
If you have ten minutes: watch the clip, read the flow-control defect, then read
the five known gaps at the end.
Description
Mission Control is a top-level view that shows every agent pane at once, so you
can watch several agents run and see which one is waiting without visiting each
session in turn. Three people have asked for this.
The base is PR #382 by @0x92,
cherry-picked so the commit and its authorship stay intact. Everything after it is
a separate commit on top. The feature ships as Mission Control, the name we
use for this surface across the product, replacing the original "Agent Fleet".
Several Claude Code agents working at once in a scratch repository. The clip
regroups by status and back to project, promotes a tile to a live terminal under
the pointer, and switches column counts.
Full-resolution MP4
· full-resolution still
· sources on
mission-control-evidence.What it does
Open it from the sidebar rail or from the expanded tree, and you get:
grouped by project, status or agent.
xterm under the pointer, or all of them with "All live".
the agent's full screen.
N, moving withthe arrow keys, closing a pane.
Two constraints from the original PR are load-bearing, and both are documented
where they are enforced:
absolute cursor positioning, and a mismatched width wraps every line. A tile
therefore never resizes the agent's PTY, which is why it fits by choosing a font
size instead.
as the single voice on that PTY.
How it got here
Ten commits, oldest first below. The first is the contribution; the rest are the
cleanup, split so each is reviewable on its own. Skip this table unless you want
the trail.
Add Agent Fleet: a live grid of every agent pane(@0x92)Rename Agent Fleet to Mission ControlactiveView, the two IPC channels, the CSS hook, files and symbols.Refactor … to current main's gates and fix the viewer sweepSimplify Mission ControlFix the stopped-tile transcript, the terminal font, and idle costAddress the review: one ack consumer, and four correctness fixesFix a flow-control jam and six findings from a fresh reviewFit tile content to the tile, snapshot and live alikeScale the column cap and the expanded tile to the displaySimplify pass: stop bypassing the shared fit, and drop what nothing readsThe conflict resolution
Main moved under the branch: #472 (title-bar context), #476 (Window Controls
Overlay), #478 and #480 (animations) all touched the same shell chrome. The one
substantive resolution is in
main/src/ipc/runpane.ts: #382 extracts threescreen-text helpers into
services/panels/terminalScreenText.ts, and main hadindependently refactored those same helpers onto the boundary decoder in #420.
The extracted module carries main's version.
Defects found and fixed along the way
Every one of these was caught by review before merge rather than by a user after
it. They are listed in full because the reasons are worth knowing, and all of
them are closed.
Flow control credited the same bytes twice. This is the one that reaches
outside the feature.
pendingBytesis one counter per PTY, and every viewerthat acks a broadcast chunk credits it again, so the high watermark was never
reached and the PTY was never paused. VS Code's terminal takes the same shape
(one counter, a single designated consumer) and never puts two xterms on one
PTY. Acks now carry a viewer id and main designates one consumer per panel.
This also closes the same double-ack between a panel and the Remote PWA, which
predates this feature.
The first attempt at that fix jammed every terminal, which is the risk named
at the top.
terminal:setVisibilityscoped a bare viewer id tolocal:<id>and the new
terminal:ackpassed the same id through raw, so a panelregistered under one string and acknowledged under another. Every ack from a
visible terminal was discarded,
pendingBytesclimbed to the high watermark,and the safety timer force-resumed without clearing the counter, so a busy
build would have delivered roughly one chunk every five seconds. Scoping now
happens in one place, and a test registers scoped, acknowledges bare, and
fails without the fix. It is worth knowing that the first version of that test
passed either way, because it fed already-scoped ids to both sides.
The stale-viewer sweep never matched anything. Main swept the prefix
mission-control:while the renderer mintedmissionControl:<uuid>, and thematcher appends its own separator, so a prefix ending in a colon can never
match. The prune timer was inert and a crashed renderer left every hovered
panel pinned visible. Both processes read one shared constant now.
A stopped agent's tile showed a mangled transcript.
alternateScreenBufferis the raw PTY byte stream, and a full-screen TUI paintsit with absolute cursor positioning, so stripping the escapes ran every word
together. Panels persist the emulator's laid-out
screenTextnow, exit persistsbefore teardown, and
runpane panels screengets the same fix.Closing Pane Chat killed it and reported success.
panels:deletedestroyedthe terminal, then declined the permanent panel and returned quietly. It refuses
before teardown now, and the grid offers no close action for a permanent panel.
Four smaller ones, all fixed: focusing a tile rebuilt and re-hydrated its
terminal; a finished agent kept a live tile; deleting an agent in a background
session wedged the sidebar badge; past the 64-panel cap a tile could go live
with no PTY dimensions and fall back to 80x24.
Responsiveness
The expanded tile cut its terminal off mid-word, because the font was fitted to
the whole wrapper while the rendered terminal also carries about 26px of its own
chrome. Snapshot bodies never fitted at all: they rendered at a fixed 11px and let
the tile clip whatever missed, so an unpromoted tile clipped silently at every
width while a live one scaled. Both paths share one fit module now, and the
clipped-right fade is shown by both rather than looking like a rendering fault in
either.
The column cap is derived from what a conventional 80-column screen needs at the
legibility floor, measured against the configured terminal font, so the same
density setting means more columns on a large display and fewer on a laptop. The
4x setting asks for four columns and the grid gives as many as fit:
Measured from 380px to 1440px: zero elements escaping the window, zero horizontal
document scroll, in both the normal grid and the expanded state.
Idle cost
Idle cost follows the existing
terminalPowerModesetting rather than a policy ofthis view's own. Performance keeps the poll and the hovered tile alive while the
window is behind something else; Battery Saver drops them, which is what
TerminalPanelalready does with the same setting.Type of Change
Checklist
pnpm typecheckandpnpm lintlocallyCritical Areas Modified
acknowledgement of PTY output is now gated on a designated viewer per
panel, and a panel persists the emulator's screen text when its PTY exits.
Both reach every terminal, not only Mission Control, so this box is
checked deliberately rather than by omission.
mission-control:channels, registeredthrough the shared command registry and covered by
daemonRegistryBindings.test.ts)Manual tests
Driven against a development build on an isolated
PANE_DIR, with a scratchrepository and several Claude Code agents working at once.
pnpm lint,pnpm typecheckpnpm --filter main exec vitest runpnpm --filter frontend exec vitest runpnpm test:ci:minimalAdditional Notes
AGENTS.md picks up an Architecture Invariants section
It arrives from #382 and reaches well past this feature: the 7-file IPC dance, the
claudeshim thatpnpm installwrites, the Windows dev data directory,CommandRunner, where schema actually lives, panel types, the shared WebGL textureatlas, and the lint rules that fail a PR. It is worth reading rather than skimming.
Known gaps, deliberately left for later
StatusAccentBar, so aworking agent's tile accent differs from the dot beside it. The fork looks
deliberate (a grid of green idle tiles would be noise) but it should be settled
one way or the other.
MissionControlAgentTypecoversclaudeandcodex, so Cursor panes list as"Agent" under "Other agents" and cannot be grouped by type.
Nshortcut is a raw document listener rather than a registeredhotkey, so it is invisible to the shortcuts overlay and ignores the
keyboard-shortcuts setting.
json_extractover every terminal panel'sstate blob, which is where scrollback lives. It wants an indexed column.
#485.
Latest automated QA
Passed at
59189e2c. The maintained minimal Electron suite passed 27/27 tests. A focused mocked Mission Control journey also passed for a populated grid, status grouping, four-column selection, group collapse, and a 420px viewport with no document-level horizontal overflow. Evidence uses synthetic repository and agent data only.Remaining human check: real PTY interaction with live agents, especially terminal-query replies and hover/focus promotion. The PR's existing manual test record covers those paths on the earlier head; this run did not repeat them against a real agent process.