Skip to content

fix(panel): count the Tickets badge as rendered groups, and cut the tab's refresh cost - #143

Merged
StuBehan merged 5 commits into
mainfrom
perf/tickets-and-sessions-tabs
Jul 29, 2026
Merged

fix(panel): count the Tickets badge as rendered groups, and cut the tab's refresh cost#143
StuBehan merged 5 commits into
mainfrom
perf/tickets-and-sessions-tabs

Conversation

@StuBehan

Copy link
Copy Markdown
Collaborator

The Tickets tab got slow as the handoff ledger grew. Measured on a real 90-day
ledger (446 records, 97 groups, 244 distinct repo+branch pairs across 32 repos):

before after
git spawns per Tickets open 1,557 346 cold, 32 warm
that in wall time 142s 32s cold, 3.0s warm
GraphQL round-trips 249 40, skipped entirely on a revisit inside 2 min
main-thread publishes per PR fetch 249 at most 37
Tickets rows built per keypress 345 the visible slice
ledger sorts per panel render 1 0

What was wrong

  • The Tickets list was not lazy. A plain VStack built every group header
    and every branch sub-row on each render. The selection index is published
    state the view reads, so holding an arrow key rebuilt all 345 rows per keypress.
  • refreshPullRequests was per branch. Two git remote get-url spawns and
    one blocking GraphQL request per branch, then a single dictionary key written
    back on main each time, so one refresh caused 249 whole-panel re-renders.
  • refreshOutcomes resolved refs per branch. Two rev-parses plus a re-walk
    of the base-branch candidates for every branch.
  • Both refreshes ran unthrottled on every onAppear, so flipping to the tab
    re-paid the whole cost.
  • The tab-strip badge re-sorted the ledger on every render of every tab, since
    it sits above the mode switch.

Changes

Each commit typechecks standalone.

  1. perf(panel): render the Tickets list lazilyLazyVStack, matching the
    Sessions and Events lists, plus an allocation-free walk to the selected row id.
  2. perf(panel): scan events once, for the selected session only — the two
    per-row scans of the event store become one, and only the selected row needs
    it, since it is the only row that renders the nudge line.
  3. fix(panel): count the Tickets badge as rendered groups — see below.
  4. perf(panel): batch and throttle the Tickets tab refreshes — one aliased
    GraphQL query per 25 branches per repo; one show-ref per repo instead of
    per-branch rev-parse; outcomes cached against the git values they were
    derived from; both refreshes behind a coalescing rate limiter.

Behaviour changes

  • The Tickets badge number drops (134 to 97 on the ledger above). It keyed by
    ticket ?? branch, counting every unticketed branch separately, while the tab
    buckets unticketed work per repo; one checkout alone contributed 34 branches
    counted 34 times and always shown as one row. The badge and the tab footer now
    agree by construction. The list itself is unchanged.
  • PR/CI state refreshes at most every 2 minutes (local ship status every 30s,
    and it is keyed on branch tips so it updates on commit/push/pull, not a timer).
    Explicit actions bypass the limit: enabling GitHub linking and completing
    sign-in fetch immediately. If 2 minutes reads as stale, the follow-up is an
    R-key manual refresh on that tab rather than a shorter window.

Ship-status semantics are deliberately unchanged, including the existing
treatment of a session recorded on a detached HEAD and the stale-local-main
weakness in a fork workflow. Both are worth revisiting separately.

Verification

swift test cannot run on the authoring machine (Command Line Tools has no
XCTest), so this leaned on other checks:

  • Differential against the old implementation over the real ledger and real
    working copies:
    all 244 repo+branch pairs produce identical status, and the
    warm-cache pass reproduces the cold pass exactly. This caught a real regression
    before it shipped: for-each-ref only matches patterns under refs/, so it
    never reports HEAD, and the four sessions recorded on a detached HEAD were
    silently becoming "deleted branch" and flipping committed to merged. Hence
    show-ref --head, verified attached and detached.
  • A measured check that lazy scrolling still works. scrollTo cannot resolve
    an id nested inside a row a LazyVStack has not built, and these anchors live
    inside the group card. Measured by which rows realise: an id on the direct child
    moved the viewport, the nested id moved nothing, which would have broken
    Cmd-Down. Each card now carries a coarse anchor and the scroll takes two hops.
  • 35 new tests plus ~135 assertions run through standalone harnesses, covering
    query construction against hostile branch names (slashes, quotes, braces,
    non-ASCII), batch boundaries, show-ref parsing, base-branch precedence,
    cache invalidation on commit/push/pull/new-Stop/branch-delete, and the rate
    limiter driven by an injected clock.
  • All 27 test files typecheck via an XCTest shim; build.sh green; running
    locally against a live ledger.

Considered and rejected

  • Making the ledger write async. Measured first: upsert costs 4.6ms median
    on main, once per agent turn, on a ledger capped at 1000 records. Async needs
    two injected scheduler seams or a weaker durability contract to save that. The
    async version was written, measured, and reverted.
  • Stabilising Session.elapsed and folding the two ps calls into one. The
    amplifier is gone, the list is lazy, and the parser relies on comm being the
    line's remainder while executables like Code Helper (Renderer) contain spaces,
    so a combined line cannot be split unambiguously. Real risk, no measurable gain.
  • Caching "this repo does not resolve" to skip wasted requests. Would suppress
    the retry that should happen the moment an org authorises the OAuth app.

Unrelated finding

39 private repos log Could not resolve to a Repository, which is also what
GitHub returns for a repo the token cannot see. The public repo never fails. The
app requests the repo scope, so this looks like org-level third-party OAuth app
restrictions rather than anything in this change, and it predates it. Worth its
own issue. Batching does cut the resulting log spam, which had reached 55k lines,
since errors were logged once per branch per refresh.

@StuBehan StuBehan changed the title perf(panel): cut the Tickets and Sessions tabs' render and refresh cost fix(panel): count the Tickets badge as rendered groups, and cut the tab's refresh cost Jul 29, 2026
@StuBehan
StuBehan merged commit ce2d0b7 into main Jul 29, 2026
6 checks passed
@StuBehan
StuBehan deleted the perf/tickets-and-sessions-tabs branch July 29, 2026 18:10
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.

1 participant