Skip to content

Improve sidebar performance and refresh reliability#323

Merged
sbertix merged 16 commits into
mainfrom
sbertix/sidebar-row-feature
May 16, 2026
Merged

Improve sidebar performance and refresh reliability#323
sbertix merged 16 commits into
mainfrom
sbertix/sidebar-row-feature

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented May 15, 2026

Summary

Per-row sidebar architecture so the sidebar stops re-rendering every row during coding-agent tool storms, plus a tail of perf / reliability hardening on top.

Per-row Observation scoping

Swift Observation tracks @ObservableState at the property level, so any mutation to an IdentifiedArrayOf element invalidated every observer of the aggregate. A single Claude / Codex tool-call hook (high-frequency under load) was re-rendering N rows in the sidebar plus the tab bar.

  • Lay the foundation — extract AgentPresenceManager into a AgentPresenceFeature TCA reducer; replace worktreeInfoByID with IdentifiedArrayOf<WorktreeInfoEntry> to prepare for per-row scoping.
  • Introduce SidebarItemFeature — a per-row reducer with its own reconcile path; mirror every per-row aggregate (lifecycle, diff stats, PR, running scripts, terminal projection) into a sidebarItems collection.
  • Scope rows via store.scope — each row gets its own Observation registrar so a per-row write only re-renders the affected row, not the whole list.
  • Drop the aggregate per-row state — delete the old SidebarItemModel / WorktreeInfoEntry and the parallel sets (runningScriptsByWorktreeID, archivingWorktreeIDs, deleteScriptWorktreeIDs, deletingWorktreeIDs, pendingSetupScriptWorktreeIDs). Per-row data now lives entirely on SidebarItemFeature.State.
  • Route per-row writes through actions and wire cross-feature deltasAgentPresenceFeature and WorktreeTerminalManager projection events fan out into per-row agentSnapshotChanged / terminalProjectionChanged actions; diff, PR, lifecycle, and running-scripts mutations all flow through per-row actions instead of being written from parent reducers. Back-channel closures on WorktreeTerminalManager (sendPresenceAction, hasAgentActivity, agentsForSurfaces) are gone.
  • Migrate helpers to State methods — move free-floating sidebar helpers onto RepositoriesFeature.State; wire pullRequestQueryStarted from production so the watermark is set at dispatch rather than in tests.
  • Cleanup pass — collapse parallel lifecycle booleans into a single Lifecycle enum, make surfaceToItemID a computed property so the reverse index can't drift, delete dead rosterChanged / RosterDelta action, call syncSidebar after pin / unpin moves, centralize the orphan-row drop in reconcileSidebarItems, extend XCTAssertSidebarConsistent to assert per-bucket order, unify sidebarDisplayName on SidebarItemFeature.State.

Reliability and perf hardening

  • Run agent-presence liveness sweep off main with race-safe apply — move the periodic kill(2) check off the main actor through a new livenessSweepResult action that carries both the snapshot and the alive delta. The apply step subtracts only the pids the sweep proved dead from the current record, so any .sessionStart or .sessionEnd that lands during the off-main hop stays authoritative. Regression tests cover both mid-hop sessionStart and sessionEnd races.
  • Improve GitHub PR fetch resilience under gateway timeouts — smaller chunks (5 branches × 5 PRs) keep statusCheckRollup under the GraphQL gateway's 504 threshold on busy CI repos. On a timeout, retry once after a 1s backoff driven by @Dependency(\.continuousClock) so cancellation propagates and tests can drive fake time. Classify at the runGh catch site by pattern-matching ShellClientError.stderr (HTTP[/0-9.]* 504) rather than substring matching the full description; new GithubCLIError.gatewayTimeout case. Test locks the "retry once, then propagate" contract.
  • Dispatch pullRequestChanged for queried-but-missing worktrees — when a worktree is in the batch request snapshot but the response omits it (branch deleted upstream, PR closed and pruned), the row never received pullRequestChanged and its pullRequestBranchAtQueryTime watermark stayed pinned, blocking every future refresh. Union the queried IDs with the response keys so every queried row clears its watermark exactly once.
  • Remove unused tabIsRunningById bookkeeping — the dict was private with no external observers; its only read was the equality-guard write itself. isTabBusy already computes from the surface tree on demand.
  • Prevent sidebar counter truncation under narrow widths — pin the trailing accessory HStack to its natural width so +N / -N diff counts, the PR number, and agent avatars stop collapsing to . Title takes the squeeze instead.

Test plan

  • make build-app passes.
  • Targeted suites green: AgentPresenceFeatureTests (incl. new mid-hop sessionStart / sessionEnd race tests), GithubCLIClientTests (incl. new gateway-timeout retry + propagation tests using ImmediateClock), RepositoriesFeatureSidebarTests (incl. new queried-but-missing watermark test), SidebarItemFeatureTests.
  • Smoke-test: run several agents in parallel, scroll the sidebar — only the active rows should redraw, not the whole list.
  • Smoke-test the sidebar at narrow widths: counters stay visible, title gets the squeeze.
  • Watch a long-running CI repo for 504 spam: should retry-once then surface clearly.

Close #256

sbertix added 13 commits May 15, 2026 23:22
- Apply the first round of sidebar / tab-bar perf patches addressing
  re-renders during coding-agent tool storms.
- Extract `AgentPresenceManager` into its own `AgentPresenceFeature`
  TCA reducer.
- Replace the `worktreeInfoByID` dictionary with an
  `IdentifiedArrayOf<WorktreeInfoEntry>` to prepare for per-row scoping.
- Add a per-row `SidebarItemFeature` reducer with reconcile path.
- Invoke the reconcile step from `applyRepositories` whenever the roster
  changes.
- Mirror every per-row aggregate (lifecycle, diff stats, PR, running
  scripts, terminal projection) into the new `sidebarItems` collection
  after each write.
- Migrate per-row reads to `state.sidebarItems[id:]` so views can
  source from the row state directly.
Swift Observation tracks `@ObservableState` at the property level, so any
mutation to an `IdentifiedArrayOf` element invalidated all N row observers
and made the sidebar lag during coding-agent tool storms. Scoping each
row through `store.scope(state: \.sidebarItems[id:], action: \.sidebarItems[id:])`
gives every row its own observation registrar, so a per-row write only
re-renders the affected row.
- Delete `SidebarItemModel` and `WorktreeInfoEntry`; per-row data now
  lives entirely on `SidebarItemFeature.State`.
- Mutate row state directly and remove the parallel aggregate sets
  (`runningScriptsByWorktreeID`, `archivingWorktreeIDs`,
  `deleteScriptWorktreeIDs`, `deletingWorktreeIDs`,
  `pendingSetupScriptWorktreeIDs`).
- Migrate every test off the aggregate state to drive `sidebarItems`.
- Add cross-feature delegate routes so `AgentPresenceFeature` and
  `WorktreeTerminalManager` projection events fan out into per-row
  `agentSnapshotChanged` / `terminalProjectionChanged` actions.
- Route diff, pull-request, lifecycle, and `runningScripts` mutations
  through per-row actions instead of mutating from parent reducers.
- Move terminal-focus token and drag-highlight flags from aggregate
  sets onto per-row state.
- Delete the back-channel closures from `WorktreeTerminalManager`
  (`sendPresenceAction`, `hasAgentActivity`, `agentsForSurfaces`).
- Cover the archived-row carry-forward case in
  `XCTAssertSidebarConsistent` and remove the related false-positive.
- Move free-floating sidebar helpers onto `RepositoriesFeature.State`
  and drop the sync-bridge.
- Wire `pullRequestQueryStarted` from production so the watermark is
  set at dispatch time rather than in tests.
- Trim caller-enumeration paragraphs from the migrated helpers so the
  surface stays explanatory without rotting on consumer renames.
- Collapse parallel lifecycle booleans into a single `Lifecycle` enum
  and group all lifecycle predicates next to the enum.
- Make `surfaceToItemID` a computed property over `sidebarItems` so the
  reverse index can no longer drift out of sync.
- Delete the dead `rosterChanged` / `RosterDelta` action and rewrite
  the stale-PR guard test to drive initial state directly.
- Call `syncSidebar` after pin / unpin / pinned-move / unpinned-move so
  the cached `sidebarGrouping` projection matches `state.$sidebar`
  immediately.
- Centralize the orphan-row drop in `reconcileSidebarItems` and move
  archived-row `runningScripts` cleanup into the same path.
- Extend `XCTAssertSidebarConsistent` to assert per-bucket order, not
  just set membership.
- Unify `sidebarDisplayName` on `SidebarItemFeature.State`, extract a
  `resetRowLifecycleSyncBeforeReconcile` helper, and tidy up the
  remaining em dashes.
- Hoist the computed `surfaceToItemID` once before the agent presence
  fan-out loop.
Move the periodic `kill(2)` liveness check off the main actor through a new
`livenessSweepResult` action that carries both the snapshot and the alive
delta. The apply step subtracts only the pids the sweep proved dead from the
current record, so any `.sessionStart` or `.sessionEnd` that lands during
the off-main hop stays authoritative.

Renames the helpers to match their shapes (`liveness(forSnapshot:)` returns
a delta; `applyLiveness(delta:snapshot:into:)` merges it back) and adds
regression tests for both the mid-hop sessionStart and sessionEnd races.
Smaller chunks (5 branches × 5 PRs) keep the `statusCheckRollup` payload
under the GraphQL gateway's 504 threshold on busy CI repos. When a chunk
still trips the timeout, retry once after a 1s backoff driven by the
injected continuous clock so cancellation propagates and tests can drive
fake time.

Classify the timeout at the `runGh` catch site by pattern-matching the
`ShellClientError` stderr (`HTTP[/0-9.]* 504`) rather than substring
matching the full command + stdout + stderr string. Adds a typed
`GithubCLIError.gatewayTimeout` case for retry-eligible callers, plus a
test that locks the "retry once, then propagate" contract.
When a worktree was included in the batch request snapshot but the
response omits it (branch deleted upstream, PR closed and pruned), the
row never received `pullRequestChanged` and its
`pullRequestBranchAtQueryTime` watermark stayed pinned. The row's
equality guard then suppressed every subsequent refresh for that branch.

Union the queried IDs with the response keys so every queried row clears
its watermark exactly once and stays eligible for the next periodic
refresh.
The dict was private with no external observers; the only read was the
write itself in `updateRunningState`. Tab dirty state is computed
on-demand by `isTabBusy` from the surface tree, so the dict was dead
storage and the equality-guard write it gained protected nothing.
Pin the trailing accessory HStack to its natural width so the +N/-N
diff counter, the PR number badge, and the running-agent avatars stop
collapsing to ellipses when the sidebar is narrow. The title takes the
squeeze instead with the existing tail-truncation behaviour.
…eature

# Conflicts:
#	supacode/Commands/WorktreeCommands.swift
#	supacode/Features/Repositories/Views/SidebarItemsView.swift
@tuist
Copy link
Copy Markdown

tuist Bot commented May 15, 2026

🛠️ Tuist Run Report 🛠️

Builds 🔨

Scheme Status Duration Commit
supacode 1m 26s 27f1603be

`SidebarListView.body` was synthesizing `[SidebarItemFeature.State]` via
`orderedSidebarItems(includingRepositoryIDs:)`, which reads
`sidebarItems[id:]` for every row and observation-tracks every property
of every row's state. Any per-row mutation then invalidated the parent
list body, defeating the per-element `store.scope` we set up for row
isolation.

Add an ID-only flavor that walks `sidebarGrouping` (stored, roster-only)
and pass `hotkeyIDs: [Worktree.ID]` down through `SidebarListView`,
`SidebarSectionView`, `SidebarRootView`, `SidebarItemsView`, and
`SidebarFolderRow`. The shortcut-index dictionary and the folder
shortcut-hint lookup now work off plain IDs.

Also fix a latent Cmd+N misroute: `rebuildSidebarGrouping` was appending
pending worktree IDs to `bucket[.unpinned]`, but `sidebarItemGroups`
renders pending rows before non-pending unpinned. The bucket now
prepends pending, so the hotkey order matches the visual order while a
worktree is being created.

Harden the shortcut-index dictionary against duplicate IDs: a forged
bucket roster used to trap inside the SwiftUI render loop. It now keeps
the first slot and `assertionFailure`s in DEBUG.

Document the bucket/items consistency invariant on `SidebarGrouping`
and add doc warnings on both `orderedSidebarItems` flavors so future
render-path callers reach for the ID variant.

`SidebarView`'s focused-scene-value `visibleHotkeyWorktreeRows` still
uses the full `[SidebarItemFeature.State]` flavor; that path feeds the
menu bar which needs the row details, and it does not invalidate the
list render.
@sbertix sbertix force-pushed the sbertix/sidebar-row-feature branch from 095b733 to 6139195 Compare May 16, 2026 00:32
sbertix added 2 commits May 16, 2026 02:41
`SidebarView` was publishing `[SidebarItemFeature.State]` through
`focusedSceneValue(\.visibleHotkeyWorktreeRows)`. Every per-row
mutation (PR query started/finished, lifecycle change, running-script
tick) re-pushed a new array, which re-evaluated `WorktreeCommands`'
body, rebuilt `CommandMenu("Worktrees")` and the sibling Window /
View / Help groups, and made AppKit drop the user's hover mid-open.

Ship a lightweight `HotkeyWorktreeSlot { id, name, repositoryID }`
projection (Equatable / Hashable). The slot carries only fields the
menu actually consumes and that mutate exclusively during sidebar
reconcile, so the focused-scene-value dedupes across the noisy
per-row ticks. Same story as the original #289 fix, different
trigger this time.
`worktreeID(byOffset:)` was iterating each expanded repository's raw
`repository.worktrees` list, which ignored the pinned / unpinned
bucket order, didn't include pending rows, and let archived rows
sneak in. Cmd+Down landed on whatever git's enumeration order
happened to give us, so arrow navigation, Cmd+1..9 slot selection,
and the visible sidebar all walked three different sequences.

Route navigation through `orderedSidebarItemIDs(...)` so all three
agree: [main, pinnedTail, pending, unpinnedTail] across expanded
repositories.
@sbertix sbertix enabled auto-merge (squash) May 16, 2026 00:48
@sbertix sbertix merged commit 0a1ed57 into main May 16, 2026
2 checks passed
@sbertix sbertix deleted the sbertix/sidebar-row-feature branch May 16, 2026 00:55
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.

Supacode gets sluggish after long multiple sessions

1 participant