cmux NEXT: one pull request card for every pull request cmux mentions

Merged
TE
16d ago
+56178-51305

Why

Leo, 2026-10-20: a #22777 chip in an agent reply showed only a generic link card
("github.com · https://github.com/.../pull/22777, Open"), while the older inline card for
leo-workspace#636 was richer (state, title, +1,234 −177) but said
"GitHub rate limit · resets 9:11 PM", because it called GitHub once per hover.

So: one pull request card for every PR mention, and it never calls GitHub.

The card

card dark

State pill (draft, open, merged) and a conflicts pill next to it, repo#N, the title, the author
and when the pull request last moved, base ← head, a checks summary, the review decision and the
labels, then the files count with +/−. Light scheme, same card:

card light

The author monogram is drawn from the login, not fetched: a card costs no network and tells
github.com nothing about what you are reading.

A link with no pull request behind it keeps the ordinary link card, unchanged:

plain link

Lines by kind

Hover (or focus) the +/− and the lines break down by kind; a click pins it open so you can read
it without holding the pointer still.

lines by kind

+2,724 stops being one number: 526 lines of code, 315 of tests, and 1,862 of generated files —
which is the honest size of the change. When code changed and no test did, the card says so:

no tests

Where the data comes from

The page never calls GitHub. It asks the app (pr.inspect), which answers at once from its cache
and reads gh in the background; opening a reply registers that reply's pull request links so they
are read once, not once per hover — and all of them in one gh api graphql, not one gh pr view per link, so a reply mentioning sixteen pull requests costs GitHub one call. A read that fails keeps the last good answer, retries in five
minutes, and the card prints the answer's age instead of an error where the pull request should be:

stale

That is the fix for what Leo saw: "read 23 minutes ago" rather than "GitHub rate limit · resets
9:11 PM".

No capture: nothing here draws a native view. The three Swift files this touches
(AgentPaneReplyLinks, AgentPaneReplyRequest, AgentReplySites) read pull requests and wire a
pane method; every pixel the change adds is in the webview, and the stills above are that UI,
rendered from the gallery's own fixtures. A tour:agent-pane or tour:core run would photograph
unchanged AppKit.

One file-kind classifier

webviews/src/fileKinds.ts. The card's breakdown and the diff viewer's grouped file list must
agree on what a file is, so one module decides and both read it. The rules, in order:

  • the host's declared generated list (the diff sidecar's generatedPaths, from gitattributes) wins
    over everything;
  • lockfiles, tool output (*.pb.go, *.min.js, project.pbxproj, *.generated.*),
    generated/ and node_modules/ trees, and translation catalogs (.xcstrings) are
    Generated — but not a vendor/ folder, because this repo keeps 203 hand-written Swift files
    under Vendor/appkit-native/; a vendored tree that really is unread says so in .gitattributes,
    which arrives as the declared list;
  • prose (.md, .rst, …) is Docs wherever it sits, so plans/mermaid-ondine-EVAL.md is a
    plan, not an eval;
  • a file's own name beats its folders, and among names "test" wins: agent-fixtures.test.ts and
    test_browser_eval_domrect.py are Tests;
  • among folders the narrower one wins, so everything under Tests/Fixtures/ is a Mock, and a
    spec/ folder is not a test folder (it holds a protocol's source of truth as often as a test:
    cmux-tui/spec/api-compat.json), because a spec file that is a test says so in its name;
  • a document is its whole name, never its stem, so CHANGELOG is Docs while changelog.ts,
    security.ts and changes.css are the Code that serves documents;
  • a word that merely contains a kind's letters stays Code (latest.ts, evaluate.ts,
    contest.ts), and a script that writes generated files is not one (gen-strings.mjs,
    gen-type-scale.py).

Sanity check over all 30,614 tracked files in this repo: 21,958 code, 6,145 tests, 1,478 docs,
631 mocks, 395 generated, 7 evals — and no hand-written file under a Vendor/ folder called
generated.

The delineated diff

The diff viewer reads the same classifier: once the stream finishes, files sort into kind order
(code, tests, mocks, docs, evals, generated) and each header names its group while a change spans
more than one kind. "Group files by kind" in the view options turns it on, and it starts off: a
reorder of the file column that a reader cannot durably turn off has no business being a default
(familiar first). It becomes the default when it can be remembered. The classifier also
decides what opens collapsed behind "Load diff", which replaces the old hard-coded lockfile list.

That is why the toggle is session-local, and deliberately not dressed up as persisted: the host
accepts only the diff.* keys it has schema rows for, so a groupByKind write would be refused
and dropped without a word. Giving it a row is the whole new-setting checklist (schema row, agent policy, 21
locales, regenerated settings exports on a Mac runner) and lands on its own, once defaults-snapshot
is green on base. webviews/test/viewer-prefs-host-keys.test.ts reads the host's own key list and
fails if the two sides ever drift apart again — which is the bug that made this worth saying.

The classifier also widens what opens collapsed behind "Load diff", and that one is not opt-in: on
this tree the old hard-coded lockfile list collapsed 65 tracked files and the classifier collapses
395. Every one of the 330 it adds is machine-written (162 .xcstrings, 59 .json, 27
*.generated.swift, 22 src/generated/*.rs, 10 .strings, 3 .pbxproj, …) — which is the point,
and it is also the PR's broadest default change, so: a localization review now opens 162 catalogs
from behind "Load diff", one click each.

The ordering waits for the stream to finish on purpose: files must not move under someone who is
already reading them. And every reorder keeps the reader on the file they were on, the toolbar's
toggle included:
useKindOrder measures the file under the viewport in the order on screen and scrolls it back
there in the commit that reordered the column, one commit later. Doing both in one effect does not
work — flushSync inside a passive effect is skipped, because React is already committing — and
that is exactly how a 300-file stream could still drop a reader onto an unrelated file.
webviews/test/kind-order.test.tsx records what the column read at each step, so the old shape
fails it. It keeps the file, not the line within it: a reader 600 lines into a long file lands at
that file's top. Pierre can anchor a line (getScrollAnchor, scrollTo({type: "line"})), so
that is a follow-up, not a limit.

Changelog

Added: a pull request card for every pull request cmux mentions, with a by-kind breakdown of its
+/− and a "no tests" warning, read from cmux's cache instead of GitHub.
Added: the diff viewer can group its files by kind ("Group files by kind" in the view options).

Observed / Not run

Two independent reviews ran. The first, of d48ed4606bc9a, came back blocked with six
user-visible defects; all six are fixed, each with the test that would have caught it: the
vendor/ and stem misclassifications above, a spec/ folder, the toggle that looked persisted and
was not, a card that said "reading…" when nothing was being read and nothing would be for five
minutes, ages worded in the host's language instead of the pane's, and the reorder that moved text
under the reader. Two findings in the data path went with them: the prefetch now uses the batched
GraphQL read instead of one gh pr view per link (github.com links; another host's, a GitHub
Enterprise one, still costs a gh pr view each), and that batched query now asks for every field
the card draws — it was silently handing back pull requests with empty branches, labels and files.

The second review, of 184fb47010ee6, confirmed those repairs and found two more, both fixed in
d85562dde445a:

  • the reorder's anchor never ran. flushSync inside a passive effect does not flush, so the
    scroll resolved against the order before the reorder. useKindOrder splits the two steps across
    commits, in both directions, and test/kind-order.test.tsx fails on the old shape — with React's
    own "flushSync was called from inside a lifecycle method" in the output.
  • the host cache's bound was not enforced. trim() dropped one link per call while a batched
    read records up to sixteen, so every reply full of links left the cache fifteen longer. It now
    trims down to maximumKept, with a test that walks 320 links through a cache that keeps 200.

It also asked for two guards, which are in: the batched query is checked against
AgentPanePullRequest.fields name by name, and EVERY_OPTION in the host-keys test is typed, so a
new viewer option has to be listed where the guard can see it.

The third review, of 1239741e735f3, passed with no blocking defect. Its named one-liners are
in anyway, because each was a claim or a guard that was not true:

  • a date the card could not read took the card down. relativeTime(Date.parse(updatedAt))
    hands NaN to Intl.DateTimeFormat, which raises RangeError — in a render, so the hover card
    goes blank over one string the host passed through. The line is drawn only when its time is a
    real time, fetchedAt is guarded the same way, and the new case in
    test/pr-card-content.test.tsx fails on the old shape with that raise inside
    PullRequestCardBody.
  • the guard test from round two could not catch its own drift. query.contains(field) searched
    the whole document, so deleting the fragment's additions still matched the one inside
    files { nodes { … } }, and deleting number still matched pullRequest(number:). It now reads
    the identifiers at the top level of fragment f on PullRequest { … } and nothing nested; I
    checked the parse against the real query (19 top-level fields, no nested leak) and against three
    deletions the old form missed.
  • "hovering a link is never a call to GitHub" was false in four comments. A hover of a link the
    minute of freshness has run out on does start a background read — one per link per minute, never
    one per hover, and never waited on. The comments now say that, because the next change would
    have read the old wording as a guarantee.
  • the measure read one render stale. useKindOrder is called above visibleItems (it decides
    the order they are built in), so its passive effect ran before useSyncedRef's and searched the
    previous render's list against the column's current tops — in the stream-complete commit, the
    one commit where those differ. The handlers go in with the list in a layout effect, which runs
    before any passive effect of the same commit.
  • prose named for its subject was called generated. Four hand-written
    Resources/**/GENERATED.md ("build-web-bundles.sh writes this directory's bundle") sorted as
    generated and collapsed, against this PR's own stated rule that prose is docs wherever it sits.
    A document titled GENERATED is now docs; api.generated.md keeps its delimited suffix, and
    prose inside a generated tree stays generated.
  • the prefetch bound is per host request, not per reply. The page batches link.inspect across
    the whole page, so in a chat with more than sixteen pull request links the ones past the bound
    are read one gh pr view each when a card opens. The constant and the test now say so; raising
    it to cover a whole request, which costs one more batched read instead, is a follow-up.
  • Smaller: AgentReplyPullRequests.read(_:)'s doc claimed a caller it has not had since the header
    moved to answer, maximumFiles = 600 reads as the batched path's cap when that path asks for
    100, the seven new diff strings had no translator comments (Mocks and Evals are jargon), and
    a gallery fixture named a test file that does not exist.

Left for follow-ups, with the reviewer's reasoning: the line-level anchor above, the prefetch
bound, base-red detection, and diff.groupByKind's settings row. Also known and not fixed:
toggling "Group files by kind" mid-stream checks the row and does nothing until the stream
finishes, with no indication; and the diff viewer page has no gallery entry at all, so the kind
pill and the reorder have test evidence but no capture.

CI on the previous head found one more, and it was the card's: agent-pane-no-capsules flagged
.acpmux-pr-card__label and __warning as 999px capsules. They are 4px rects now, and that test
passes on a pane bundle built from this head.

Ran and saw, in /home/leo/.cmux/worktrees/cmux/pr-cards on big-red, at this head (base
a95a5d21b40ba merged in):

  • bun test test/kind-order.test.tsx test/viewer-prefs-host-keys.test.ts test/file-kinds.test.ts test/diff-kind-groups.test.ts test/pr-card-content.test.tsx test/bare-pr-refs.test.tsx — 43 pass, 0 fail, and test/agent-pane-no-capsules.test.ts green on a pane bundle built here;
  • test/kind-order.test.tsx red on the flushSync shape and green on this one, and the new date
    case red on the old Date.parse shape and green on this one (each reverted in the working tree,
    run, and restored);
  • bun run check in webviews/ — no warnings, lint errors or type errors in 2,213 files, every source lane passed, and scripts/web-lint.sh green;
  • scripts/cmux-next/prepush-gate.py — no new violations;
    scripts/lint-ios-package-conventions.sh --namespace-fix — no unjustified violations;
    scripts/cmux-next/check-l10n.sh — 131 tables, 7,912 keys, 21 languages, 0 errors;
  • the classifier over all tracked files, with the counts above and zero hand-written Vendor/
    files called generated;
  • the six stills above, rendered from the gallery's own fixtures.

One of this round's fixes ships without a test, and it is the App.tsx one: the stale-list hazard
lives in the wiring between useKindOrder and useSyncedRef, and test/kind-order.test.tsx
drives the hook with its own handlers, so it passes on both shapes. Seeing it would take a full
App render against a real CodeView. The verification pass proved the ordering by hook index
instead — layout effects of a commit run before any of its passive effects, so the handlers and the
list are this commit's by the time the measure reads them, in every case including mount and the
useDeferredValue double commit.

Not run here: no macOS build and no Swift test on this host (fleet rule), so
AgentChatPullRequestReader, the AgentReplyPullRequests bound and their tests are
read-and-reviewed, not executed, until the Swift jobs are green. No app dogfood: the diff
viewer's reorder is covered by the hook's test, not by a run in a real pane.

On the base this head carries (a95a5d21b40ba), bun run check is clean outright — no warnings,
lint errors or type errors over 2,213 files — and scripts/web-lint.sh passes.

Swift is compiled, on the fleet, not here: cmux-ci compile-check --app passed on this head (60 s)
and on its parent, and fleet test-compile at 89f225cc54085 compiled every test target of
Packages/macOS/CmuxNext, naming both of this change's test files —
Compiling CmuxNextAgentPaneTests AgentPanePullRequestInspectTests.swift and
Compiling CmuxNextAppTests AgentReplyPullRequestsTests.swift (CmuxNextAppTests compiled 119s,
CmuxNextAgentPaneTests compiled 19s, "every planned test target compiled (6)"). The catch-up
merges after that receipt brought base Swift changes but touched none of the five Swift files this
change edits, so the receipt still covers them.

Every red on this PR was read, and each is base's, traced to the commit that made it and
reproduced in a clean base worktree where that was possible:

  • cmux-next guards / cmux-next localization debt ratchet —
    ChiefViews.xcstrings: identical 0 -> 3. python3 -I scripts/localization_catalog.py check in a
    worktree of base alone prints the same three strings, the same note about
    CmuxNextHome's needs_review 964 -> 972, and the same 42446 parity errors, byte for byte.
    This change produces that same number, so it adds no debt.
  • cmux-next crash-class rules — HomeSidebarSplitView+Drop.swift:11: super.viewDidMoveToWindow … probe needed. Same single failure on base alone (906 super calls, 184 runtime keys, 1 failure(s)); it wants a probe run on a Mac.
  • cmux-next checks — ERROR namespace-type … ChiefWorkspaceName.swift:6 (all-static public surface), from #23391. The same class of violation in HomeThoughtHeadline was fixed by #23308
    while this PR was open, which is how I know what it is; the convention fixer offers to repair
    this one too, and I reverted that repair rather than carry another lane's file.
  • gallery health (0) and (2) — agent-pane.artifact-panel#versions ("no element for button
    "Open preview"") and agent-pane.composer-row#fast-on ("waitFor did not hold within 5000 ms"),
    whose sources (ArtifactPanel.gallery.ts, composerRow.gallery.ts) are not among this change's
    33 files and were last touched by #23347 and #22800.
  • Earlier heads also showed composer-claude-split.test.ts expecting no tooltip on + (#23149
    added one) and TIMEOUT composer-row-layout.test.ts; base has since updated that test itself.

The one red that was mine is fixed: PR capture check wants a capture or a reason for a change
whose paths map to a Mac tour, and this one maps through three Swift files that draw nothing. The
No capture: line above is that reason, and scripts/ci/pr_capture_check.py run locally against
this body and this head's paths now prints "shown by: no-capture reason" and exits 0.

Two notes on scope. "Whether the failing checks are base reds" is in the brief but not in this PR,
and it is a cheaper follow-up than it first looked: the base ref's rollup goes into the batched
query that is already there (repository { ref(qualifiedName: baseRefName) { target { … on Commit { statusCheckRollup } } } }), deduped by base ref, so a reply whose pull requests all target
feat-cmux-next adds one subselection rather than a query per card. The base ref's tip is the
right thing to measure, not a per-pull-request merge base: "red at the base tip" is the definition
the merge rules use. Whoever builds it must not intersect the two context lists naively, though —
on a branch with concurrency groups a cancelled run leaves failed checks in the rollup that a
newer run of the same workflow has already replaced, so a naive comparison would tell a reader
"that red is base's" about a red that is theirs. Bucket the contexts by workflow, keep only the
highest workflowRun.databaseId per workflow, and treat older run ids as superseded;
glaeda-gh's parse_pr solves the same problem the same way, and its field set is the one known
to carry enough to do it (StatusContext nodes have state, not conclusion, and
contexts(first: 100) truncates on a busy base). Thanks to the gh-broker lane for the warning.
And diff.groupByKind has no settings row yet, for the reason given above; all of this is
follow-up work, not a silent omission.

One note on where the data comes from, since it was asked. The brief said "only from glaeda-gh's
cache, or one batched query through it". glaeda-gh is fleet tooling, not something a user's Mac
has — it appears in this repo only in .github/workflows and one test fixture — so an app-side
reader cannot route through it without making the card work on the fleet and nowhere else. The
contract it stands for is what this implements, host-side: a hover reads only the cache, a reply's
links cost one batched query, each link is re-read at most once a minute, a failed read keeps the
last good answer for five, and the card prints the answer's age where an error would otherwise be.

Agent session: big-red

🤖 Generated with Claude Code