Skip to content

Detail-view + menu-bar performance: per-tab observation, snapshot caches, FocusedAction wrapper#329

Merged
sbertix merged 10 commits into
mainfrom
sbertix/detail-view-performance
May 17, 2026
Merged

Detail-view + menu-bar performance: per-tab observation, snapshot caches, FocusedAction wrapper#329
sbertix merged 10 commits into
mainfrom
sbertix/detail-view-performance

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented May 17, 2026

Summary

  • Closes navigating the menu causes an unexpected removal of the options #289 (menu-bar drops items / loses hover state when the focused worktree has a running agent) by stabilizing focused-value identities with a generic FocusedAction<Input> wrapper, caching a single WorktreeMenuSnapshot for the Commands body, and splitting the menu into independently-observed Commands structs.
  • Improves Supacode gets sluggish after long multiple sessions #256 (detail-view rendering perf during agent storms) by refactoring the terminal-tab system into a per-tab TerminalTabFeature so per-tab churn invalidates only that leaf, caching selectedSlice + toolbarNotificationGroups in reducer state behind an exhaustive CacheInvalidations switch, and dropping the floating GhosttySurfaceProgressOverlay in favor of the per-tab top stripe.
  • Tightens contract invariants flagged in review: per-tab observation no longer leaks orphan TerminalTabFeature.State on prune, the recently-removed-tab FIFO is worktree-scoped and drains on teardown, eventStream replays per-tab projections so re-attach reseeds the per-tab collection, and TerminalTabID is nonisolated struct so the per-tab state can be keyed by the nominal type instead of raw UUID.

Test plan

  • make test — 1290 tests, 0 failures (11 pre-existing known issues)
  • make build-app — clean release build
  • make lint / make format — clean
  • New tests cover the worktree-scoped FIFO, the worktreeStateTornDown drain, the same-session snapshot-restore flow that previously would have been shadowed, the per-tab state-projection invariants, the cached selectedSlice / toolbarNotificationGroups contracts under agent storms.
  • Manual: launch the app on a worktree with a running agent, open the Worktrees / File / View menu, hover items and confirm hover state survives across agent ticks (regression check for navigating the menu causes an unexpected removal of the options #289).
  • Manual: prune + unarchive a worktree (covers C2's snapshot-restore path) and confirm tab features come back in the bar.

Notes

  • DEBUG _printChanges() / SupaLogger("DetailRender") instrumentation is intentionally retained behind #if DEBUG so a future navigating the menu causes an unexpected removal of the options #289 regression is traceable from the console.
  • Branch went through a multi-agent argue-wrap review; final score 9.5/10 with all 12 accepted claims landed (1 claim rejected on closer inspection — see commit messages for the per-claim mapping).

sbertix added 10 commits May 17, 2026 20:54
Each tab now has its own TerminalTabFeature, with agentPresence scoped to
the leaf via `store.scope(state: \.agentPresence, ...)`. Detail body no
longer reads `store.state.agentPresence` directly, so agent-storm ticks
stop invalidating the whole body and republishing focused-scene values
(the upstream cause of #289 menu drops on agent-active focus).

Per-tab leaves:
- TerminalTabAgentBadge reads `agentStore.state.agents(across:badgesEnabled:)`
  so agent tool storms invalidate the badge leaf only.
- TerminalTabNotificationIndicator reads `terminalState.hasUnseenNotification`
  itself for the same reason.

Stripe carries the work-in-progress signal:
- Drop GhosttySurfaceProgressOverlay / GhosttySurfaceProgressBar entirely.
  The existing top-of-tab stripe paints the focused surface's state (focused
  tab) or a worst-of aggregate (unfocused). ERROR > PAUSE > determinate >
  indeterminate > none. Stripe extends across L/R separator borders so the
  active tab reads continuous.
- `TerminalTabView.accessibilityValue` carries state-aware text
  (\"Busy\", \"Paused\", \"Errored\", \"47 percent complete\") so VoiceOver
  regains the OSC-9 signal the deleted overlay used to surface.

Tab UI polish:
- Hotkey hint typography matches the sidebar verbatim (caption + secondary)
  and now sits in the trailing close-button slot. Hover swaps hint → X.
- TerminalTabDivider and TerminalTabBackground's bottom border use
  `@Environment(\.pixelLength)` + `.separator` style.
- New TerminalTabAgentBadgeContent (View, Equatable) mirrors sidebar's
  RunningAgentsBadgeContent so the avatar group dedupes on agent equality.
- Rename `WorktreeTerminalNotification.surfaceId` → `surfaceID`.

Tests:
- Two state-projection regression tests pin per-tab input stability: a
  notification on tab B leaves tab A's unseen count unchanged, and an agent
  record on tab B leaves tab A's `agents(across:badgesEnabled:)` unchanged.
Tab stripe:
- Extract `TerminalTabProgressStripe` and render it as an `.overlay` on
  `TerminalTabView` AFTER `clipShape`. Previously the stripe lived inside
  `TerminalTabBackground` (applied via `.background`), so the corner-radius
  clip trapped its `-pixelLength` horizontal padding and left a 1px gray
  notch at each tab boundary. The overlay sits above the clip and paints
  across the adjacent dividers.
- When the tab carries no custom tint and no progress state, the stripe
  falls back to `chromeAppearance.overlayTint` at a new
  `secondaryAccentOpacity` (0.45 dark / 0.35 light) instead of accent.
  Active plain tabs now read as a subtle chrome-tone indicator, not an
  accent-color flash.
- `TerminalTabBackground` keeps only the bottom border for inactive tabs;
  the border now paints with `chromeAppearance.overlayTint.opacity(separatorOpacity)`
  (matching `TerminalTabDivider`) instead of `.separator`.

Trailing slot:
- Hover wins over ⌘-pressed hotkey hint. A new `isShowingHint` gate is
  passed to both the hint conditional and `TerminalTabCloseButton.isShowingShortcutHint`
  so hovering a tab with ⌘ held swaps the hint out for the close button
  instead of leaving the slot empty.

Toolbar title chrome:
- `WorktreeToolbarTitleView` now manages its own chrome-scheme tracking
  1:1 with `WindowTintColorScheme` (same env + state + notification
  subscription, same `manager.surfaceBackgroundColorScheme()` source).
  The toolbar lives outside `windowTintColorScheme`'s wrapper, so the
  chrome env doesn't reach there; the title view tracks the dependencies
  itself and injects both `\.surfaceChromeAppearance` and `\.colorScheme`.
  Title text now stays readable across "Supacode Terminal Theme" toggles
  and system Light/Dark flips, including the light-system / dark-terminal
  combination that previously made the branch / repo name vanish.
- Render path split into a `WorktreeToolbarTitleBody` struct subview so the
  outer view just owns the chrome plumbing.
Detail body now reads cached projections instead of `sidebarItems[id:]` and
`terminalManager`, so per-leaf agent and notification churn no longer
invalidates the detail tree. Both caches recompute via a single post-reduce
hook driven by an exhaustive `CacheInvalidations` switch over every action,
making a missing case a compile error instead of a silent skip.

`runningScriptIDs` sources from the cached slice so the running-script
duplicate check doesn't pull observation through `sidebarItems[id:]`. Drop
unused `repositoryAccent` / `hasMergedBadge` from the slice and collapse the
local cache-flag aliases onto `CacheInvalidations.all` for single-source
consistency.

Negative tests in `SelectedWorktreeSliceCacheTests` pin the load-bearing
#289 contract: agent and projection storms on the focused row leave the
slice and notification caches byte-equal.
Late tabProjectionChanged emits arriving after tabRemoved were re-creating
phantom tab states. Added a bounded recent-removals deque on TerminalsFeature
and added TestStore coverage for projection short-circuiting, removal idempotence,
and the replay-after-remove guard.
WorktreeCommands now observes one Equatable WorktreeMenuSnapshot cached on
AppFeature.State and refreshed in a post-reduce hook. Split the body into
WorktreeMainMenu, WorktreeFileMenu and a static SelectWorktreeSubmenuItems
so each Commands struct re-renders only when its own observed inputs flip.

Select Worktree 1..10 are now always present; the reducer beeps for
out-of-range slots, and Select Next/Previous beep at the bounds instead of
disabling.

Adds DEBUG `Self._printChanges()` + `SupaLogger(\"DetailRender\")` on the
#289 invalidation chain (WorktreeDetailView / WorktreeToolbarTitleView /
WorktreeToolbarTitleBody / ContentView and the menu-snapshot diff) so a
future regression on this hot path is traceable from the console.
Closure-typed focused values invalidate the AppKit menu on every body
run because closures have no Equatable conformance, so SwiftUI re-publishes
each time. Wrap every menu-bar focused action in a generic
FocusedAction<Input> that dedupes on (isEnabled, token) so AppKit only
rebuilds when something the menu actually displays changes.

ContentView, WorktreeDetailView, SidebarView and ArchivedWorktreesDetailView
now publish via .focusedSceneAction / .focusedAction. Consumers in
WorktreeCommands, TerminalCommands, WindowCommands, SidebarCommands read
the action with @focusedvalue and gate via `action?.isEnabled != true`.
ContentView additionally moves the heavy commandPaletteItems / WindowTitle
reads into dedicated host subviews so per-row sidebar churn no longer
invalidates ContentView's body. Alert / DeleteWorktreeTarget /
ArchiveWorktreeTarget / DeleteDisposition gain Hashable so they can ride
the token slot. AGENTS.md documents the pattern as the default.
closeAllSurfaces (called from prune) torn down the WorktreeTerminalState
without firing onTabRemoved for each tab, leaking a phantom
TerminalTabFeature.State in TerminalsFeature.State.terminalTabs for every
tab in every pruned worktree. The same path also never replayed per-tab
projections on a new event-stream subscriber, so tabs in re-attached
worktrees stayed empty in the per-tab feature collection until the next
mutation.

Changes:
- closeAllSurfaces emits onTabRemoved for each tab in lastTabProjections
  before draining the per-tab caches (C1).
- prune emits a new TerminalClient.Event.worktreeStateTornDown after
  closeAllSurfaces. TerminalsFeature drains recentlyRemovedTabIDs and
  any orphan terminalTabs rows for that worktree so a same-session
  unarchive that restores persisted tab UUIDs starts clean (C2).
- recentlyRemovedTabIDs FIFO is now keyed by (worktreeID, tabID) so the
  same UUID restored under a different worktree would not be shadowed.
- eventStream replays each WorktreeTerminalState.currentTabProjections()
  and currentTabProgressDisplays() to the new continuation so a fresh
  subscriber rebuilds terminalTabs[id:] without waiting for mutations.
  Wires the previously dead currentTabProjections() helper (C3 + C7).
- TerminalTabID is declared  so its synthesized
  Hashable witness stays nonisolated under Swift 6 strict concurrency.
  TerminalTabFeature.State.id is typed TerminalTabID instead of UUID,
  dropping the .rawValue conversions at every scoping site and walling
  off mixing with unrelated UUIDs (C12).
- WorktreeTerminalState.notifications is now private(set); a DEBUG-only
  setNotificationsForTesting(_:) helper fans emitAllTabProjections()
  so the per-tab projection contract stays load-bearing in production
  (C10).

Tests cover worktreeStateTornDown draining the FIFO + orphan rows, and
the snapshot-restore-after-teardown flow that previously would have been
shadowed by the FIFO.
The post-reduce hook fired recomputeWorktreeMenuSnapshotIfChanged after
every reducer action. The Equatable diff prevented SwiftUI invalidation
but the recompute itself (URL flatMap on the slice's pullRequest, eight
scalar reads, struct allocation, eight-field equality check) ran on the
agent-storm hot path the rest of the branch is trying to keep cold.

`AppFeature.Action.affectsWorktreeMenuSnapshot` is an exhaustive switch
(no `default`) covering every top-level case and every TerminalClient.Event
variant. Hot agent-storm shapes (.agentPresence, .terminals, .terminalEvent
projection/progress/tab churn, .repositories actions with empty
cacheInvalidations) opt out; .settings and .terminalEvent.notificationIndicatorChanged
opt in; the rest is enumerated explicitly so a new action shape is a
compile error rather than a silent stale snapshot.
`.worktreeInfoEvent` was mapped to `.all` but the arm is a pure
effect-launcher: HEAD-watcher ticks paid for computeSidebarStructure +
computeSelectedWorktreeSlice + computeToolbarNotificationGroups (full
per-repo iteration) just for the Equatable diff to find a no-op (C11).
Move it to the explicit `[]` group; the downstream arms it dispatches
(`.worktreeBranchNameLoaded` / `.repositoryPullRequestsLoaded`) already
declare their own invalidations.

`.worktreeBranchNameLoaded` was mapped to `[.sidebarStructure,
.selectedWorktreeSlice]` but the handler calls `updateWorktreeName(...)`,
which mutates `worktree.name` which feeds
`computeToolbarNotificationGroups()` (notification group title). A
branch-name reload on a worktree with notifications would leave the
toolbar group's name stale until an unrelated bulk action recomputed
(C5). Promote to `.all`.

Extract the three-step post-reduce body to
`RepositoriesFeature.State.applyCacheRecomputes(_:)`. The production hook
and the test mirror both call it so a future fourth cache lands in one
place instead of needing two coordinated updates (C13).
`SelectedWorktreeSlice.sidebarDisplayName` and `SidebarItemFeature.State.sidebarDisplayName`
were byte-for-byte copies of the same `isMainWorktree → id-contains-"/" →
URL last-path → subtitle → branchName` cascade. The cache exists so the
detail title can read off the slice; a future edge-case fix to one would
silently leave the other stale. Extract the cascade as a `static` on a
caseless `SidebarDisplayName` enum and have both call sites delegate.
`WorktreeAccent.derive(isMainWorktree:isPinned:)` does the same for the
parallel two-line cascade (C6).

Drop the dead `RepositoriesFeature.State.selectedWorktreeSlice(for:)`
helper. Repo-wide grep shows zero callers; the documented "ad-hoc by ID"
use case has no consumer (C8).
@sbertix sbertix enabled auto-merge (squash) May 17, 2026 19:55
@tuist
Copy link
Copy Markdown

tuist Bot commented May 17, 2026

🛠️ Tuist Run Report 🛠️

Builds 🔨

Scheme Status Duration Commit
supacode 1m 51s 9a54c31a4

@sbertix sbertix merged commit 955c194 into main May 17, 2026
2 checks passed
@sbertix sbertix deleted the sbertix/detail-view-performance branch May 17, 2026 20:02
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.

navigating the menu causes an unexpected removal of the options

1 participant