fix(sidebar): stop the CI spinner on a closed PR chip - #1045
Conversation
A session whose transcript mentions a closed (not merged) pull request rendered its sidebar chip as `#993 Closed` plus a perpetually spinning "checks running" icon. The spinner never settled: a closed PR's check rollup never advances, because GitHub parks fork-PR checks in PENDING / ACTION_REQUIRED when the PR is closed before a maintainer approves the run, so the backend keeps faithfully projecting `ci: "running"`. The chip gated all three CI glyphs on `link.state !== 'merged'` only. `closed` is just as terminal — a closed PR can never merge, so its CI rollup carries no actionable information. PR #301 established exactly this reasoning ("once merged, CI state is moot") but covered only `merged`, and the detail panel's `SourceTabState` already suppresses CI for BOTH terminal states — so the chip and the source-strip tab rendered two different truths for the same pull request. Name the terminal vocabulary once (`TERMINAL_SOURCE_LINK_STATES` + `showsChipCi`) and gate the three glyphs on it, so one rule covers both states instead of a literal repeated per glyph. An absent `state` stays non-terminal: the provider status has not been read yet, so such a chip keeps rendering CI as before. The backend is faithful and unchanged. PR #301's regression test lived in the since-split ChatSidebar.integration.test.tsx and did not survive, leaving the whole rule uncovered — which is why the `closed` half went unnoticed. The new table covers both terminal states, a live control, and an unknown-state control. Fixes #1020
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Real terminal-state bug, fixed at the render-rule root cause with the vocabulary named once, aligned with the panel's existing rule, and non-vacuously tested. [DESIGN-REVIEWED] 46c9b93 |
UX Review (Fable 5) — ✅ PASSAdvisory UX-level review of UX-Verdict: PASS Removes an infinite spinner from persistent sidebar chrome and aligns the chip with the panel's existing terminal-state rule — strictly less noise, no new surface. [UX-REVIEWED] 46c9b93 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsArbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Both line-level reviewers (Opus 5 and GPT 5.6) reported no findings, and the design and UX reviewers each returned a clean PASS with no CONCERNS. There are therefore zero sub-threshold findings to evaluate for escalation, and nothing substantive was demoted to a follow-up. The diff itself is a narrow, well-tested frontend render-rule fix (suppressing the CI rollup on terminal PR chips) plus a spec update — no contract, schema, persisted-data, or wire-format decisions are locked in by merging it. [ARBITER-REVIEWED] 46c9b93 False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
Fixes #1020
Problem
A session whose transcript mentions a closed (not merged) pull request renders its sidebar chip as
#993 Closedplus a "checks running" spinner that never stops. The user sees a session advertising live CI work on a pull request that can never merge and that nobody is waiting for.The spinner cannot settle on its own. A closed PR's check rollup never advances: GitHub parks fork-PR checks in
PENDING/ACTION_REQUIREDwhen the PR is closed before a maintainer approves the run, so_rollup_cikeeps correctly projectingci: "running"for as long as the link stays in the transcript. The chip spins for the life of the session.Why it matters
The chip is a status signal, and a permanently-spinning one is worse than no signal: it claims work is in flight on a dead pull request, and the sidebar's own "Closed" badge sits right next to it saying the opposite. The same chip also disagreed with the Changes panel, which already suppressed CI for closed pull requests — so the two surfaces rendered two different lifecycles for the same PR, which is exactly the class of divergence #443 set out to eliminate.
Fix (symptoms → root cause → change)
Symptom: a
closedchip shows the red "Closed" label and an endless spinner.Root cause: the chip gated all three CI glyphs on
link.state !== 'merged'— a per-glyph inline literal that covered only one of the two terminal states:closedis as terminal asmerged: a closed pull request can never merge, so its CI rollup carries no actionable information. #301 ("hide CI status icons on merged PR chips") established precisely this reasoning — "once merged, CI state is moot — the merge icon is the terminal signal" — but implemented onlymerged.PullRequestPanel.tsx::SourceTabStatealready had the rule right for both states (lifecycle === 'merged' || lifecycle === 'closed' ? undefined : status?.ci), so the sidebar was the lone outlier.Change: name the terminal vocabulary once and gate the three glyphs on it, so one rule covers both states instead of a literal repeated three times — the shape that let
closedbe handled by the badge but missed by the CI gate:An absent
statestays non-terminal — the provider status has not been read yet (or the payload predates the field), so such a chip keeps rendering CI exactly as before. A state outside the known set (GitLablocked, which the backend deliberately projects as no state) also still renders CI, identical to the old!== 'merged'behavior, so there is no regression for unexpected values.SourceLinkStateis derived from the wire type (NonNullable<NonNullable<Slot['source_links']>[number]['state']>) rather than re-typed, so the set is compiler-checked against the real vocabulary and cannot drift silently. Both call sites now cross-reference each other in comments, since the chip and the panel tab must not disagree about one pull request's lifecycle.The backend is faithful and unchanged.
_project_statecorrectly reportsclosedand_rollup_cicorrectly reports the rollup it was handed; this was purely a render-condition bug.Tests
website/src/test/ChatSidebar.sourceLinkChip.test.tsx— the harness is parameterized to take chip fixtures, and a new table locks in the whole rule (every fixture carriesci: 'running', sostateis the only variable):closedchip — the reported bug.mergedchip — fix(sidebar): hide CI status icons on merged PR chips #301's assertion lived inChatSidebar.integration.test.tsx, which has since been split up, and did not survive; there was no test covering CI suppression in either terminal state, which is why theclosedhalf went unnoticed. Restored here.openchip and a chip with nostateat all, so the negative assertions cannot pass vacuously and the absent-state path is pinned as non-terminal.passed/failedCI glyph on a closed chip too (it.each) — all three glyphs, not just the spinner.Non-vacuity proven, not assumed: with
'closed'removed from the terminal set, exactly 3 of these tests fail (AssertionError: expected SVGSVGElement to be null) while themergedand live-PR controls keep passing. Restored, all 8 in the file pass.docs/system-specs/modules/learn-cron-dashboard.mdis updated in the same commit: the chip description now states the terminal-state suppression rule, why a closed rollup can stay pending forever, that both surfaces apply the same rule, and that an absent state is not terminal.Manual verification
N/A — unit coverage is sufficient and stronger than a manual pass here. The change is a pure render-condition guard whose entire input space is the four-value
stateunion plusundefined, and the test table enumerates every branch of it (closed,merged,open, absent) against all three CI glyphs. A manual repro would additionally require a real closed pull request whose check rollup is parked inPENDING, which is not reproducible on demand.Gates run locally:
pytest(22783 passed),isort/flake8/mypy(clean, 567 files),npm run build(tsc -b+ vite, clean),npm test(6547 passed / 541 files),jscpd(0 clones),eslinton the changed files (0 errors),i18n:check(at baseline). The three backend failures and one frontend failure seen locally were each confirmed to fail identically on pristinemainwith these changes absent — see the note below.Pre-existing local failures (verified independent of this change)
Confirmed by re-running them on pristine
origin/mainwith this branch's changes absent:test/test_app_backend.py::TestBootSpawnLatency::test_survival_check_exits_early_for_a_healthy_child— timing budget, load-sensitive.test/test_cli.py::TestInstallPidfdChildWatcher::test_real_subprocess_works_after_install_on_linuxtest/test_module_loader.py::TestModuleUnload::test_reload_after_unload_gets_fresh_codewebsite/src/i18n/format.test.ts— assertsIntl.DurationFormatis absent; it exists on Node ≥ 23. Local Node is v24.14.0, CI pins Node 20, so this is local-only.None touch the sidebar chip path.
Screenshots
Not included. The change removes an icon from an existing chip and adds no new UI surface, colour, layout, or component — the affected pixels are one
<Loader2>glyph that should no longer be painted, which the tests assert on directly byaria-label. Reproducing the "before" state additionally needs a real closed pull request with a permanently-pending rollup. This follows the precedent of #301, the same change in the merged case, which shipped with no images for the same reason.Notes for review (deliberately out of scope)
Three items were identified and consciously left alone rather than widening the diff:
ChatSidebar.tsx(over the sidebar's localSlotsource-link type) andPullRequestPanel.tsx(overPullRequestStatus['state']). Unifying them would require a new shared module, since the two types are declared independently. Both sites now name the other in a comment so a future change to one is pointed at the other.closedlabel is not localized —<span className="capitalize text-danger">{link.state}</span>renders raw English while the adjacent merged marker usesi18nT. Pre-existing, on a line this PR does not change, and fix(i18n): localize agent-capabilities nav, composer placeholder, session status, and approval-mode picker #1016 is actively localizing this file.npm run typecheckis a no-op — the rootwebsite/tsconfig.jsonis{"files": [], "references": [...]}, andtsc --noEmitdoes not follow project references, so it typechecks nothing (verified: a deliberate type error inChatSidebar.tsxpassestsc --noEmitand is caught only bytsc -b). The real check runs viatsc -binsidenpm run build, so coverage is not actually lost — but thetypecheckandcheckscripts are misleading. Pre-existing and repo-wide; worth its own issue.