Skip to content

Add Mission Control: a live grid of every agent pane - #484

Open
parsakhaz wants to merge 16 commits into
mainfrom
feature/mission-control
Open

Add Mission Control: a live grid of every agent pane#484
parsakhaz wants to merge 16 commits into
mainfrom
feature/mission-control

Conversation

@parsakhaz

@parsakhaz parsakhaz commented Aug 20, 2026

Copy link
Copy Markdown
Member

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.

  • Where to look hardest: one change reaches outside this feature into shared
    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".
  • Risk that already surfaced once: that same change introduced a jam that
    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.
  • Everything else stays inside the new view.
  • Verified by driving the real app with live agents, not only by tests:
    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".

Mission Control with several agents running

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:

  • A grid of tiles, one per agent pane, with a status accent, branch and age,
    grouped by project, status or agent.
  • Snapshots that are cheap ANSI-stripped text by default. A tile becomes a real
    xterm under the pointer, or all of them with "All live".
  • Typing into a focused tile without leaving the grid, optionally expanding it to
    the agent's full screen.
  • Folding projects away, jumping to the next blocked agent with N, moving with
    the arrow keys, closing a pane.

Two constraints from the original PR are load-bearing, and both are documented
where they are enforced:

  • A tile renders at exactly the PTY's dimensions. Agent TUIs paint with
    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.
  • A tile stays silent on the agent's terminal queries, leaving the real panel
    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.

Commit What
Add Agent Fleet: a live grid of every agent pane (@0x92) The feature, cherry-picked from #382 with conflicts resolved against current main.
Rename Agent Fleet to Mission Control Strings, sidebar entries, activeView, the two IPC channels, the CSS hook, files and symbols.
Refactor … to current main's gates and fix the viewer sweep Lint conformance, boundary-decoded payloads, guarded persisted options, one real defect.
Simplify Mission Control Shared derivations, de-duplicated markup, removed payload fields nothing reads.
Fix the stopped-tile transcript, the terminal font, and idle cost Six review findings.
Address the review: one ack consumer, and four correctness fixes Codex review.
Fix a flow-control jam and six findings from a fresh review Including a jam this branch introduced.
Fit tile content to the tile, snapshot and live alike The overflow fix.
Scale the column cap and the expanded tile to the display Responsiveness.
Simplify pass: stop bypassing the shared fit, and drop what nothing reads Four-angle quality pass.

The 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 three
screen-text helpers into services/panels/terminalScreenText.ts, and main had
independently 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. pendingBytes is one counter per PTY, and every viewer
    that 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:setVisibility scoped a bare viewer id to local:<id>
    and the new terminal:ack passed the same id through raw, so a panel
    registered under one string and acknowledged under another. Every ack from a
    visible terminal was discarded, pendingBytes climbed 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 minted missionControl:<uuid>, and the
    matcher 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.
    alternateScreenBuffer is the raw PTY byte stream, and a full-screen TUI paints
    it with absolute cursor positioning, so stripping the escapes ran every word
    together. Panels persist the emulator's laid-out screenText now, exit persists
    before teardown, and runpane panels screen gets the same fix.

  • Closing Pane Chat killed it and reported success. panels:delete destroyed
    the 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:

Viewport width (px) Columns at the 4x setting Tile width
3840 4 927px
2560 4 607px
1920 4 447px
1440 2 664px
800 1 698px

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 terminalPowerMode setting 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, which is what
TerminalPanel already does with the same setting.

Type of Change

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the CONTRIBUTING.md guidelines
  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (AGENTS.md)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally

Critical Areas Modified

  • Session output handling (requires explicit permission). Flow-control
    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.
  • State management/IPC events (two new mission-control: channels, registered
    through the shared command registry and covered by daemonRegistryBindings.test.ts)

Manual tests

Driven against a development build on an isolated PANE_DIR, with a scratch
repository and several Claude Code agents working at once.

What Result
Open Mission Control from the sidebar rail and from the expanded tree Both reach the grid
Several live agents plus Pane Chat One tile each, header counts agents and how many are working
Tile content while agents work Snapshots advance with the agents' output
Hover a tile Exactly one tile promotes to a live xterm, built once
Click a tile, then Collapse Expands to the agent's screen and returns to grid size at 1440, 1100 and 800
An agent asking a question Tile sorts to the front with the "Needs input" accent
Group by status, agent, none, and back to project Regroups in place, counts follow
Column counts 1x through 4x Honoured as far as the width allows, then capped
Window widths 380px to 1440px No overflow in either the grid or the expanded state
Exit an agent, then read its tile Shows the agent's real last screen, correctly laid out
Pane Chat tile No close action, and opening it goes to Pane Chat
pnpm lint, pnpm typecheck Pass
pnpm --filter main exec vitest run 68 files, 644 tests pass
pnpm --filter frontend exec vitest run 28 files, 290 tests pass
pnpm test:ci:minimal 27 Playwright tests pass

Additional 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
claude shim that pnpm install writes, the Windows dev data directory,
CommandRunner, where schema actually lives, panel types, the shared WebGL texture
atlas, and the lint rules that fail a PR. It is worth reading rather than skimming.

Known gaps, deliberately left for later

  • Tile status accents use their own colour map rather than StatusAccentBar, so a
    working 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.
  • MissionControlAgentType covers claude and codex, so Cursor panes list as
    "Agent" under "Other agents" and cannot be grouped by type.
  • The grid's N shortcut is a raw document listener rather than a registered
    hotkey, so it is invisible to the shortcuts overlay and ignores the
    keyboard-shortcuts setting.
  • The agent roster query filters on json_extract over every terminal panel's
    state blob, which is where scrollback lives. It wants an indexed column.
  • Preload duplicates the daemon-owned channel lists; that is
    #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.

0x92 and others added 3 commits August 20, 2026 14:55
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
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

React Doctor found 6 new issues in 2 files · 1 error & 5 warnings · score 70 / 100 (Needs work) · 0 fixed · vs main

Errors

5 warnings

src/components/missionControl/MissionControlView.tsx

  • ⚠️ L216 Large component is hard to read and change no-giant-component
  • ⚠️ L253 Ref initializer runs on every render rerender-lazy-ref-init
  • ⚠️ L475 State update after await in an effect no-set-state-after-await-in-effect
  • ⚠️ L611 Effects chained together no-effect-chain

src/hooks/useMissionControlTerminal.ts

  • ⚠️ L664 State adjusted after a prop changes no-adjust-state-on-prop-change

Reviewed by React Doctor for commit 59189e2. See inline comments for fixes.

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
@parsakhaz
parsakhaz force-pushed the feature/mission-control branch from 84ab592 to 2a27ef1 Compare August 20, 2026 22:14

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread frontend/src/hooks/useMissionControlTerminal.ts Outdated
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
Comment thread frontend/src/components/missionControl/MissionControlView.tsx Outdated
Comment thread frontend/src/components/missionControl/MissionControlView.tsx Outdated
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
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
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
Comment thread frontend/src/components/missionControl/MissionControlView.tsx Outdated
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
Comment thread frontend/src/components/missionControl/MissionControlView.tsx Outdated
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
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
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
Comment thread frontend/src/components/missionControl/MissionControlView.tsx
parsakhaz added a commit that referenced this pull request Aug 21, 2026
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.
@parsakhaz
parsakhaz force-pushed the feature/mission-control branch from d8237bc to 4a40012 Compare August 21, 2026 09:50
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 parsakhaz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review pass 1: correctness and security

Three Must-Fix flow-control defects were found. No security defects were found.

  1. Remote terminal acknowledgements bypass designated-viewer arbitration. frontend/src/remote/runtime/remoteRuntimeAdapter.ts:187 sends terminal:ack without the viewer ID, while visibility registration includes it. main/src/services/terminalPanelManager.ts:836 credits unidentified acknowledgements without checking the designated viewer, and main/src/daemon/httpApiServer.ts:785 scopes only visibility calls. With desktop and Remote PWA watching one PTY, the same chunk can be credited twice, defeating the high watermark.

  2. Mission Control acknowledges chunks before xterm consumes them. frontend/src/hooks/useMissionControlTerminal.ts:535 credits queued pre-hydration output immediately, and the hydrated path credits immediately after scheduling terminal.write. The primary terminal correctly acknowledges from xterm's write callback at frontend/src/components/panels/TerminalPanel.tsx:1548. A busy agent can therefore outrun the tile parser without triggering backpressure.

  3. Mission Control's acknowledgement unit differs from main's debit unit. Main debits data.length at main/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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

const tiles: MissionControlTileModel[] = useMemo(
() => {
const next = reconcileTileModels(tilesRef.current, agents, agentStatus, snapshots);
tilesRef.current = next;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs


// Poll snapshots only while this view is actually on screen. A background
// poll would keep every agent's emulator warm for nothing.
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

* confirmation, its last snapshot, its element — refers to something that no
* longer exists, and a close confirmation left open would fail on confirm.
*/
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Docs

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Docs

@parsakhaz

parsakhaz commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Review, simplify, refactor, and QA complete

Head: 59189e2c

Review

The correctness and security review found three Must-Fix defects in terminal flow control. All are fixed in e2be817a (fix(review): preserve terminal flow control), and the second review passed with zero Must-Fix or Should-Fix findings.

  • Remote PWA acknowledgements now carry the registered viewer ID and receive the same authenticated runtime scoping as visibility.
  • Mission Control and Remote PWA acknowledge only after xterm consumes output.
  • Main, primary terminal, Mission Control, and Remote PWA now use the same UTF-8 byte unit.
  • Regression tests cover remote scoping, viewer propagation, delayed batching, disposal flushing, and multibyte output.

GitHub does not allow an author to submit “request changes” on their own PR, so the Must-Fix review was posted as a formal comment review with the same findings.

Simplify

5d05670e (refactor(simplify): reduce Mission Control state branches) made two behavior-neutral reductions across the full touched-file review:

  • One generation counter now owns stale roster request invalidation instead of two counters that always moved together.
  • One derived canInteract predicate now owns tile live-render and activation eligibility.

The before and after frontend baseline is identical: 33 files and 342 tests passed, with typecheck and lint clean.

Refactor

59189e2c (refactor: centralize terminal viewer invariants) applied only changes with direct test proof:

  • Secondary terminals now preserve exact PTY widths above the old 400-column cap. New tests cover 401 and 512 columns.
  • Primary TerminalPanel and Mission Control share one tested acknowledgement accumulator.
  • The unread MissionControlSnapshotResult.capturedAt field was removed from main, shared types, and the E2E mock.

The deep verification finished at 9.8/10 with zero criticals or warnings.

Tests run

  • pnpm typecheck: passed
  • pnpm lint: passed, including Oxlint, ESLint, boundary conformance, and Knip
  • Frontend Vitest: 33 files, 344 tests passed on final head
  • Focused main Vitest: 6 files, 163 tests passed on final head
  • Review-fix focused suites: 12 frontend and 74 main tests passed
  • pnpm test:ci:minimal: 27 Playwright tests passed
  • Focused Mission Control Playwright journey: 1 passed

UI QA evidence

Synthetic marker: Mission Control QA, three mocked agent panes. No production or private data was used.

State Evidence
Populated project grid Three panes render in the new top-level view with project grouping and density controls.
Populated Mission Control grid
Status grouping and 4× request Controls switch grouping and requested density while the responsive cap keeps tiles readable.
Mission Control grouped by status
Collapsed group The status group collapses and reports its count.
Collapsed Mission Control status group
420px viewport With the app's compact sidebar, controls wrap inside the viewport and the document has no horizontal overflow.
Mission Control at 420 pixels wide

The uploaded PNGs were downloaded again and matched their source SHA-256 hashes.

Follow-ups

  • Add component-level orchestration tests before splitting the 1,046-line MissionControlView into roster, polling, selection, keyboard, and rendering units.
  • Add terminal lifecycle tests before introducing a shared desktop, Mission Control, and Remote PWA terminal-viewer boundary.
  • Measure and migrate agent roster identity out of panel-state JSON, with backfill and query-plan tests, before optimizing the roster query.
  • Decide Cursor's Mission Control grouping and labels, then reuse the canonical agent type with grouping coverage.

Left for parsa

  • All required GitHub checks passed on macOS, Ubuntu, and Windows.
  • React Doctor remains advisory at 70/100. It reports one render-purity error for
    updating the tile-model cache ref during useMemo, plus five structural
    warnings. No required check failed and no behavior regression was demonstrated,
    so this was not expanded into an unrequested fourth pass.
  • Optionally repeat the real-agent hover, focus, typing, and terminal-query checks on this final head. The existing PR manual record covers them before these three commits.
  • Merge when satisfied. No merge was performed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants