cmux NEXT: one pull request card for every pull request cmux mentions
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

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:

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:

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.

+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:

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:

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/andnode_modules/trees, and translation catalogs (.xcstrings) are
Generated — but not avendor/folder, because this repo keeps 203 hand-written Swift files
underVendor/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, soplans/mermaid-ondine-EVAL.mdis a
plan, not an eval; - a file's own name beats its folders, and among names "test" wins:
agent-fixtures.test.tsand
test_browser_eval_domrect.pyare 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
CHANGELOGis Docs whilechangelog.ts,
security.tsandchanges.cssare 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.
flushSyncinside a passive effect does not flush, so the
scroll resolved against the order before the reorder.useKindOrdersplits the two steps across
commits, in both directions, andtest/kind-order.test.tsxfails 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 tomaximumKept, 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))
handsNaNtoIntl.DateTimeFormat, which raisesRangeError— 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,fetchedAtis guarded the same way, and the new case in
test/pr-card-content.test.tsxfails 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'sadditionsstill matched the one inside
files { nodes { … } }, and deletingnumberstill matchedpullRequest(number:). It now reads
the identifiers at the top level offragment 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.
useKindOrderis called abovevisibleItems(it decides
the order they are built in), so its passive effect ran beforeuseSyncedRef'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.shwrites 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 titledGENERATEDis now docs;api.generated.mdkeeps 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.inspectacross
the whole page, so in a chat with more than sixteen pull request links the ones past the bound
are read onegh pr vieweach 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 toanswer,maximumFiles = 600reads as the batched path's cap when that path asks for
100, the seven new diff strings had no translator comments (MocksandEvalsare 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, andtest/agent-pane-no-capsules.test.tsgreen on a pane bundle built here;test/kind-order.test.tsxred on theflushSyncshape and green on this one, and the new date
case red on the oldDate.parseshape and green on this one (each reverted in the working tree,
run, and restored);bun run checkinwebviews/— no warnings, lint errors or type errors in 2,213 files, every source lane passed, andscripts/web-lint.shgreen;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 checkin a
worktree of base alone prints the same three strings, the same note about
CmuxNextHome'sneeds_review 964 -> 972, and the same42446 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 inHomeThoughtHeadlinewas 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"") andagent-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.tsexpecting no tooltip on+(#23149
added one) andTIMEOUT 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