From 726422520470b30c266bcbdaf968c16e58a5697b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 12 Jun 2026 12:29:53 +1000 Subject: [PATCH 1/5] perf(timeline): cut per-row bits-ui overlay count in TimelineRow (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the project-switch-freeze fix plan. Switching projects synchronously mounts the incoming branch tree, and ~90% of that flush is native DOM insertion of bits-ui Tooltip/ContextMenu portal trees scaling as branches × rows × ~50 overlay instances. This attacks the per-row ×~50 multiplier. - Replace the three metadata tooltips (meta, secondaryMeta, tertiaryMeta) with a native title= attribute. Their tooltip content was identical to the visible trigger text and only existed to reveal ellipsis-truncated values, which title= does with zero components. - Trim the action-button tooltips: drop the bits-ui Tooltip wrapper for static, low-value labels (Start, Retry, Resume, Diff, View session), moving the label to title=/aria-label= on the Button. Keep bits-ui tooltips only where they surface a non-obvious *DisabledReason (Pull, Push, Force Push, Rebase, Commit, Discard, Delete). - Context menu left unchanged (one per row, no native equivalent). Behavior preserved: truncated meta still reveals full text on hover, disabled-button reasons still surface, right-click menu still works. Signed-off-by: Matt Toohey --- .../lib/features/timeline/TimelineRow.svelte | 161 ++++++------------ 1 file changed, 55 insertions(+), 106 deletions(-) diff --git a/apps/staged/src/lib/features/timeline/TimelineRow.svelte b/apps/staged/src/lib/features/timeline/TimelineRow.svelte index 54bf2250..b2213023 100644 --- a/apps/staged/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/staged/src/lib/features/timeline/TimelineRow.svelte @@ -329,36 +329,15 @@ {#if meta || secondaryMeta || tertiaryMeta || (badges && badges.length > 0)}
{#if meta} - - - {#snippet child({ props })} - {meta} - {/snippet} - - {meta} - + {meta} {/if} {#if secondaryMeta} - - - {#snippet child({ props })} - {secondaryMeta} - {/snippet} - - {secondaryMeta} - + {secondaryMeta} {/if} {#if tertiaryMeta} - - - {#snippet child({ props })} - {tertiaryMeta} - {/snippet} - - {tertiaryMeta} - + {tertiaryMeta} {/if} {#if badges} {#each badges as badge} @@ -395,58 +374,40 @@ !!discardChangesDisabledReason} > {#if onStartClick} - - - {#snippet child({ props })} - - {/snippet} - - Start - + {/if} {#if onRetryClick} - - - {#snippet child({ props })} - - {/snippet} - - Retry - + {/if} {#if onResumeClick} - - - {#snippet child({ props })} - - {/snippet} - - Resume session - + {/if} {#if onPullClick || pullDisabledReason} @@ -539,22 +500,16 @@ {/if} {#if onViewDiffClick} - - - {#snippet child({ props })} - - {/snippet} - - View diff - + {/if} {#if onCommitChangesClick || commitChangesDisabledReason} @@ -597,22 +552,16 @@ {/if} {#if hasSession && !onStartClick && !isQueued} - - - {#snippet child({ props })} - - {/snippet} - - View session - + {/if} {#if onDeleteClick || deleteDisabledReason} From 141dbbdda61ade02ea617084f48c14bfac353d80 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 12 Jun 2026 12:37:36 +1000 Subject: [PATCH 2/5] perf(timeline): lazy-mount BranchCard timeline interior via IntersectionObserver (phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a of the project-switch-freeze fix plan. Switching projects synchronously mounts the incoming project's entire branch tree; the dominant cost is DOM nodes created in one flush, scaling as branches × rows × overlay-instances. Phase 1 cut the per-row overlay multiplier. This makes per-switch node count nearly independent of project size by not mounting the heavy timeline interior of off-screen branch cards. Hiding nodes (display:none / content-visibility) does not help — only not creating them does — so the interior is genuinely {#if}-gated, not merely hidden. - The cheap shell (header: PR/cloud icon, BranchCardHeaderInfo, BranchCardActionsBar, status badges) always renders. Only the heavy is wrapped in {#if shouldMountInterior} ... {:else} a height-preserving placeholder. All existing props pass through unchanged when it mounts. - An IntersectionObserver on the card root (existing cardElement bind) drives shouldMountInterior: true on enter, false on leave, so off-screen detail genuinely unmounts (extends "don't keep details mounted" to the scroll axis and relieves memory pressure). rootMargin '150% 0px' (~1.5 viewports) mounts interiors just before they scroll in and avoids mount/unmount thrash on brief scroll-bys. Observer root is the .main-panel scroll container via cardEl.closest('.main-panel'), falling back to the viewport (null) if not found. Created in an $effect and disconnected in its cleanup so switching projects can't leak observers; guarded for environments without IntersectionObserver. - Placeholder height comes from the interior's last-measured offsetHeight, tracked by a ResizeObserver while mounted and cached in a module-level Map keyed by branch.id (survives both unmount and the component being destroyed/recreated across switches). Never-measured cards use a 160px estimate, corrected on first mount. Applied via style:min-height. Side-effect check: is a presentational consumer of the `timeline` state, which BranchCard loads via its own effects/event listeners independent of the timeline being mounted — so lazy-mounting doesn't disrupt data flow or anything the header reads. Its only side effect (liveSessionHintPoller) is cosmetic live-hint polling that pauses while off-screen and restarts on re-mount; its only transient state (liveSessionHints) is repopulated by that poller, so no user-meaningful state is lost on unmount. Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 292 ++++++++++++------ 1 file changed, 190 insertions(+), 102 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 98fcb565..a74f1740 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -10,6 +10,24 @@ - Notes open a markdown viewer - Each item shows session + delete actions on hover --> + + @@ -1399,109 +1474,122 @@
{:else if timeline || isSettingUp} - loadTimeline()} - deletingItems={timelineDeletingItems} - reviewCommentBreakdown={timelineReviewDetailsById} - onSessionClick={(sid) => sessionMgr.handleTimelineSessionClick(sid)} - onResumeClick={(sid) => { - commands - .resumeSession(sid, 'Continue where you left off.', undefined, branch.id) - .then(() => loadTimeline()) - .catch((e) => { - console.error('Failed to resume session:', e); - toast.error('Resume failed', { - description: - e instanceof Error - ? e.message - : 'Could not resume the session. Please try again.', - }); - }); - }} - onCommitClick={handleCommitClick} - onNoteClick={handleNoteClick} - onReviewClick={handleReviewClick} - onDeleteCommit={handleDeleteCommit} - onDeletePendingCommit={handleDeletePendingCommit} - onDeleteNote={handleDeleteNote} - onDeleteReview={handleDeleteReview} - onImageClick={handleImageClick} - onDeleteImage={handleDeleteImage} - onStartQueued={() => { - commands - .drainQueuedSessions(branch.id) - .catch((e) => console.error('Failed to drain queued sessions:', e)); - }} - onNewNote={() => sessionMgr.openNewSession('note')} - onNewCommit={() => sessionMgr.openNewSession('commit')} - onNewReview={hasCodeChanges || sessionMgr.hasCommitSessionInProgress - ? (e) => sessionMgr.openNewSession('review', e) - : undefined} - onPullOrigin={handlePullOrigin} - onPushOrigin={handlePushOrigin} - onOpenPushSession={pushSessionId && pushSessionId !== '__pending__' - ? openPushSession - : undefined} - onRebaseBranch={() => startBranchCommandPipeline('rebase')} - onRebaseBranchOntoOrigin={() => startBranchCommandPipeline('rebase', 'origin')} - onForcePush={handleForcePush} - onOpenForcePushSession={forcePushSessionId && forcePushSessionId !== '__pending__' - ? openForcePushSession - : undefined} - {forcePushingOrigin} - rebaseBranchDisabledReason={branchCommandDisabledReason} - onViewWorktreeDiff={isLocal ? () => (showWorktreeDiff = true) : undefined} - onCommitWorktreeChanges={() => - sessionMgr.startOrQueueSession('commit', 'Commit uncommitted changes')} - onDiscardWorktreeChanges={handleDiscardWorktreeChanges} - onNewSessionReferring={(ref) => sessionMgr.openNewSessionReferring(ref)} - newSessionDisabled={sessionMgr.isNewSessionDisabled || gitUnsafeActionsDisabled} - {pullingOrigin} - {pushingOrigin} - {discardingWorktreeChanges} - {provisioningLabel} - {provisioningDetail} - > - {#snippet footerActions()} - {#if hasCodeChanges || branch.prNumber} - + {:else} + + + {/if} {/if} {/if} From 72c346379694ecae4ec4d260b15eac9532e56542 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 12 Jun 2026 12:45:19 +1000 Subject: [PATCH 3/5] perf(timeline): cap BranchTimeline rows with a show-older expander (phase 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2b of the project-switch-freeze fix plan, and the most conditional/design-gated phase (it changes default-visible content, so the truncated-older UX should get a design sign-off). The synchronous project-switch flush cost scales as branches × rows × overlay-instances: Phase 1 cut the per-row overlay count, Phase 2a stopped mounting off-screen card interiors, and this bounds the inner `rows` term so a single very long timeline can't blow up the flush even when its card is on screen. Governing principle preserved: only NOT creating DOM nodes helps, so deferred rows are genuinely unrendered (sliced array), never CSS-hidden. - Render only the latest N=20 normalItems by default (TIMELINE_ROW_CAP, a single named constant — raise it to effectively disable the cap). normalItems is sorted ascending, so the most-recent rows are the tail; we slice the tail (latest) and defer the older head rows. Only normalItems is capped — gitFooterItems, pending/drop placeholders, and the action footer render in their own loops and are untouched. The {#each} key stays (item.key); sliced rows keep their keys. - A "Show {n} older" ghost Button (house Button component, size=xs) mounts the deferred rows on demand; it sits above the rows since older items belong at the head. "Show less" collapses back to the cheap state. aria-expanded reflects state. - Expanded flag persists in a module-level Map keyed by repoDir (the branch's worktree path) so it survives Phase 2a unmounting the whole BranchTimeline when the card scrolls >~1.5 viewports off-screen. BranchTimeline isn't passed branch.id and BranchCard must not be modified, so repoDir is the most stable branch-unique id in scope; rare cloud-only branches with no worktree path fall back to a non-persisted (resets on scroll-away) flag. Mirrors BranchCard's module-scoped interiorHeightCache pattern. Composes with Phase 2a: collapsed interiors are shorter, so BranchCard's ResizeObserver simply caches the shorter height — no action needed. Verified: pnpm run check (svelte-check --fail-on-warnings + tsc) → 0 errors, 0 warnings; prettier --check clean on the edited file. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matt Toohey --- .../features/timeline/BranchTimeline.svelte | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 2ce5073d..29029302 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -6,14 +6,40 @@ Bottom git status rows appear below active and queued work. Failed sessions appear in chronological order with completed items. --> + + -