From f5b95b51cb26d4a3fec0087401d3b3071803199e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 15 Jun 2026 09:47:14 +1000 Subject: [PATCH 1/2] perf(staged): reduce project navigation and timeline churn Defer non-selected project hydration and queued session drains, centralize branch event fanout, and share timeline context menu handling. Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 147 ++--- .../branches/BranchCardActionsBar.svelte | 19 +- .../branches/BranchCardPrButton.svelte | 64 +- .../branches/ParentBranchCommitsHover.svelte | 20 +- .../diff/DiffCommitSessionLauncher.svelte | 7 +- .../lib/features/projects/ProjectHome.svelte | 261 ++++++-- .../features/projects/ProjectSection.svelte | 189 ++++-- .../features/sessions/hashtagItems.test.ts | 17 + .../src/lib/features/sessions/hashtagItems.ts | 5 +- .../features/timeline/BranchTimeline.svelte | 563 +++++++++--------- .../timeline/TimelineContextMenu.svelte | 133 +++++ .../lib/features/timeline/TimelineRow.svelte | 97 ++- .../src/lib/services/branchEventService.ts | 151 +++++ .../lib/stores/projectRunActions.svelte.ts | 22 +- 14 files changed, 1119 insertions(+), 576 deletions(-) create mode 100644 apps/staged/src/lib/features/timeline/TimelineContextMenu.svelte create mode 100644 apps/staged/src/lib/services/branchEventService.ts diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 8b77d4f21..f1c4deca5 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -41,7 +41,6 @@ import Spinner from '../../shared/Spinner.svelte'; import { isSessionActive } from '../../shared/sessionStatus'; import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; - import { listenToEvent } from '../../transport'; import { subscribeDragDrop } from './dragDrop'; import type { Branch, @@ -49,7 +48,6 @@ BranchTimeline as BranchTimelineData, HashtagItem, ProjectRepo, - SessionStatusPayload, WorkspaceStatus, } from '../../types'; import * as commands from '../../api/commands'; @@ -81,6 +79,11 @@ import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import { pushStateStore } from '../../stores/pushState.svelte'; + import { + onBranchGitStateUpdated, + onBranchSetupProgress, + onSessionStatusChanged, + } from '../../services/branchEventService'; import type { WorktreeChangesPreview } from '../../commands'; import type { LinkedNoteContext, NoteClickInfo } from '../sessions/noteFreshness'; @@ -217,22 +220,12 @@ let setupDetail: string | null = $state(null); $effect(() => { - const eventNames = ['worktree-setup-progress', 'workspace-setup-progress'] as const; - const unlisteners = eventNames.map((eventName) => - listenToEvent<{ branchId: string; phase: string; detail: string | null }>( - eventName, - (payload) => { - if (payload.branchId === branch.id) { - setupPhase = payload.phase; - setupDetail = payload.detail; - } - } - ) - ); + const unlisten = onBranchSetupProgress(branch.id, (payload) => { + setupPhase = payload.phase; + setupDetail = payload.detail; + }); - return () => { - for (const fn of unlisteners) fn(); - }; + return () => unlisten(); }); // Reset setup state when provisioning completes @@ -605,71 +598,63 @@ $effect(() => { const branchId = branch.id; - const unlistenStatus = listenToEvent( - 'session-status-changed', - (payload) => { - const { - sessionId: eventSessionId, - status, - branchId: eventBranchId, - isAutoReview, - } = payload; - if (status === 'completed' || status === 'error' || status === 'cancelled') { - // If this is the auto review session completing, just clear tracking - if (eventSessionId === sessionMgr.autoReviewSessionId) { - sessionMgr.autoReviewSessionId = null; - return; - } + const unlistenStatus = onSessionStatusChanged((payload) => { + const { sessionId: eventSessionId, status, branchId: eventBranchId, isAutoReview } = payload; + if (status === 'completed' || status === 'error' || status === 'cancelled') { + // If this is the auto review session completing, just clear tracking + if (eventSessionId === sessionMgr.autoReviewSessionId) { + sessionMgr.autoReviewSessionId = null; + return; + } - // Push/force-push session tracking lives in pushStateStore and is - // cleared centrally by sessionStatusListener.handlePushCompletion. + // Push/force-push session tracking lives in pushStateStore and is + // cleared centrally by sessionStatusListener.handlePushCompletion. - // Skip normal completion handling for any auto review session - if (isAutoReview) { - return; - } + // Skip normal completion handling for any auto review session + if (isAutoReview) { + return; + } - // Skip reload for the adopted auto-review session completing — - // the timeline was already updated optimistically during adoption. - if (eventSessionId === sessionMgr.adoptedSessionId) { - sessionMgr.adoptedSessionId = null; - return; - } + // Skip reload for the adopted auto-review session completing — + // the timeline was already updated optimistically during adoption. + if (eventSessionId === sessionMgr.adoptedSessionId) { + sessionMgr.adoptedSessionId = null; + return; + } - // Only reload if this session belongs to our branch - if (eventBranchId && eventBranchId !== branchId) return; + // Only reload if this session belongs to our branch + if (eventBranchId && eventBranchId !== branchId) return; - commands.invalidateBranchTimeline(branch.id); + commands.invalidateBranchTimeline(branch.id); + loadTimeline(); + // Handle PR session completion + if (prButton && eventSessionId === prButton.getPrSessionId()) { + prButton.handlePrSessionComplete(status); + } + // Handle push session completion + if (prButton && eventSessionId === prButton.getPushSessionId()) { + prButton.handlePushSessionComplete(status); + } + } else if (status === 'running' && eventBranchId === branchId) { + // Track auto review sessions started by the backend + if (isAutoReview) { + sessionMgr.autoReviewSessionId = eventSessionId; + commands.findFreshAutoReview(branchId).then((review) => { + if (review) { + sessionMgr.autoReviewId = review.id; + } + }); + } + // Refresh the timeline so the pending note/commit stub appears immediately. + // Skip if a session start is in-flight (pending item has no sessionId yet), + // because startBranchSessionWithPendingItem will call loadTimeline after + // it gets the sessionId — otherwise pruning can't match the pending item + // and both the pending and real items briefly render simultaneously. + if (!isAutoReview && !sessionMgr.isSessionStartPending) { loadTimeline(); - // Handle PR session completion - if (prButton && eventSessionId === prButton.getPrSessionId()) { - prButton.handlePrSessionComplete(status); - } - // Handle push session completion - if (prButton && eventSessionId === prButton.getPushSessionId()) { - prButton.handlePushSessionComplete(status); - } - } else if (status === 'running' && eventBranchId === branchId) { - // Track auto review sessions started by the backend - if (isAutoReview) { - sessionMgr.autoReviewSessionId = eventSessionId; - commands.findFreshAutoReview(branchId).then((review) => { - if (review) { - sessionMgr.autoReviewId = review.id; - } - }); - } - // Refresh the timeline so the pending note/commit stub appears immediately. - // Skip if a session start is in-flight (pending item has no sessionId yet), - // because startBranchSessionWithPendingItem will call loadTimeline after - // it gets the sessionId — otherwise pruning can't match the pending item - // and both the pending and real items briefly render simultaneously. - if (!isAutoReview && !sessionMgr.isSessionStartPending) { - loadTimeline(); - } } } - ); + }); return () => { unlistenStatus(); @@ -681,16 +666,12 @@ $effect(() => { const branchId = branch.id; - const unlistenGitState = listenToEvent<{ branchId: string; gitState: BranchGitState }>( - 'git-state-updated', - (payload) => { - if (payload.branchId !== branchId) return; - if (timeline) { - timeline = { ...timeline, gitState: payload.gitState }; - } - refreshingGitState = false; + const unlistenGitState = onBranchGitStateUpdated(branchId, (payload) => { + if (timeline) { + timeline = { ...timeline, gitState: payload.gitState }; } - ); + refreshingGitState = false; + }); return () => { unlistenGitState(); diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index b717b86f8..e506cb35b 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -26,7 +26,7 @@ import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; import ActionOutputModal from '../actions/ActionOutputModal.svelte'; - import { listenToEvent, type UnlistenFn } from '../../transport'; + import type { UnlistenFn } from '../../transport'; import type { Branch, ProjectRepo } from '../../types'; import * as commands from '../../api/commands'; import type { ProjectAction } from '../../api/commands'; @@ -36,7 +36,6 @@ clearActionExecution, stopBranchAction, getRunPhase, - listenToRunPhaseChanged, listenToRepoActionsDetection, type ActionStatusEvent, type ActionType, @@ -54,6 +53,7 @@ import { bloxEnv } from '../../stores/bloxEnv.svelte'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState } from '../agents/agent.svelte'; + import { onBranchActionStatus, onBranchRunPhaseChanged } from '../../services/branchEventService'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { Button } from '$lib/components/ui/button'; @@ -225,12 +225,7 @@ $effect(() => { const branchId = branch.id; - const unlistenActionStatus = listenToEvent('action_status', (payload) => { - // Only process events for this branch - if (payload.branchId !== branchId) { - return; - } - + const unlistenActionStatus = onBranchActionStatus(branchId, (payload: ActionStatusEvent) => { const existingIndex = runningActions.findIndex((a) => a.executionId === payload.executionId); if (payload.status === 'running') { @@ -298,11 +293,9 @@ } }); - const unlistenRunPhaseChanged = listenToRunPhaseChanged((event) => { - if (event.branchId === branchId) { - runPhases.set(event.executionId, event.phase); - runPhases = new Map(runPhases); - } + const unlistenRunPhaseChanged = onBranchRunPhaseChanged(branchId, (event) => { + runPhases.set(event.executionId, event.phase); + runPhases = new Map(runPhases); }); return () => { diff --git a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte index 2f8a6dc80..094d9a395 100644 --- a/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardPrButton.svelte @@ -15,12 +15,10 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { minuteNow, secondNow } from '../../shared/relativeTime.svelte'; - import { listenToEvent } from '../../transport'; import type { Branch, BranchTimeline as BranchTimelineData, PrFailedCheck, - PrStatusChangedEvent, Session, } from '../../types'; import * as commands from '../../api/commands'; @@ -38,6 +36,10 @@ import { prStateStore, type PrState } from '../../stores/prState.svelte'; import { pushStateStore, type PushState } from '../../stores/pushState.svelte'; import * as prPollingService from '../../services/prPollingService'; + import { + onBranchPrStatusChanged, + onBranchPrStatusCleared, + } from '../../services/branchEventService'; interface Props { branch: Branch; @@ -170,39 +172,35 @@ $effect(() => { const branchId = branch.id; - const unlistenStatus = listenToEvent('pr-status-changed', (payload) => { - if (payload.branchId === branchId) { - prStatusState = payload.prState; - prStatusChecks = payload.prChecksStatus; - prStatusReviewDecision = payload.prReviewDecision; - prStatusMergeable = payload.prMergeable; - prStatusDraft = payload.prDraft; - prHeadSha = payload.prHeadSha; - prFetchedAt = payload.prFetchedAt; - failedChecks = payload.failedChecks ?? []; - prStatusCleared = false; - // Update the polling service with the new checks status - prPollingService.updateChecksStatus( - branchId, - branch.projectId, - payload.prChecksStatus === 'PENDING' - ); - } + const unlistenStatus = onBranchPrStatusChanged(branchId, (payload) => { + prStatusState = payload.prState; + prStatusChecks = payload.prChecksStatus; + prStatusReviewDecision = payload.prReviewDecision; + prStatusMergeable = payload.prMergeable; + prStatusDraft = payload.prDraft; + prHeadSha = payload.prHeadSha; + prFetchedAt = payload.prFetchedAt; + failedChecks = payload.failedChecks ?? []; + prStatusCleared = false; + // Update the polling service with the new checks status + prPollingService.updateChecksStatus( + branchId, + branch.projectId, + payload.prChecksStatus === 'PENDING' + ); }); - const unlistenCleared = listenToEvent('pr-status-cleared', (clearedBranchId) => { - if (clearedBranchId === branchId) { - prStatusState = null; - prStatusChecks = null; - prStatusReviewDecision = null; - prStatusMergeable = null; - prStatusDraft = null; - prHeadSha = null; - prFetchedAt = null; - failedChecks = []; - prStatusCleared = true; - prPollingService.updateChecksStatus(branchId, branch.projectId, false); - } + const unlistenCleared = onBranchPrStatusCleared(branchId, () => { + prStatusState = null; + prStatusChecks = null; + prStatusReviewDecision = null; + prStatusMergeable = null; + prStatusDraft = null; + prHeadSha = null; + prFetchedAt = null; + failedChecks = []; + prStatusCleared = true; + prPollingService.updateChecksStatus(branchId, branch.projectId, false); }); return () => { diff --git a/apps/staged/src/lib/features/branches/ParentBranchCommitsHover.svelte b/apps/staged/src/lib/features/branches/ParentBranchCommitsHover.svelte index c789f2fa5..903e9cb0f 100644 --- a/apps/staged/src/lib/features/branches/ParentBranchCommitsHover.svelte +++ b/apps/staged/src/lib/features/branches/ParentBranchCommitsHover.svelte @@ -1,10 +1,10 @@ + +{#if hasActions} + + + {@render children?.()} + + + {#if activeAction} + {#if activeAction.commitSha} + + Copy SHA + + {/if} + {#if activeAction.hashtagRef && onNewSessionReferring} + + New session referring to this + + {/if} + {/if} + + +{:else} + {@render children?.()} +{/if} diff --git a/apps/staged/src/lib/features/timeline/TimelineRow.svelte b/apps/staged/src/lib/features/timeline/TimelineRow.svelte index 6cd24a751..a2568898a 100644 --- a/apps/staged/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/staged/src/lib/features/timeline/TimelineRow.svelte @@ -11,16 +11,13 @@ import FileSearch from '@lucide/svelte/icons/file-search'; import ImageLucide from '@lucide/svelte/icons/image'; import MessageSquare from '@lucide/svelte/icons/message-square'; - import MessageSquarePlus from '@lucide/svelte/icons/message-square-plus'; import Trash2 from '@lucide/svelte/icons/trash-2'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import Clock from '@lucide/svelte/icons/clock'; import GitBranch from '@lucide/svelte/icons/git-branch'; import GitMerge from '@lucide/svelte/icons/git-merge'; import ChevronsDown from '@lucide/svelte/icons/chevrons-down'; - import Copy from '@lucide/svelte/icons/copy'; import Spinner from '../../shared/Spinner.svelte'; - import * as ContextMenu from '$lib/components/ui/context-menu'; import { Button } from '$lib/components/ui/button'; export type TimelineItemType = @@ -89,12 +86,8 @@ onDiscardChangesClick?: () => void; discardChangesDisabledReason?: string; showConnector?: boolean; - /** Full commit SHA for the context menu "Copy SHA" action. */ - commitSha?: string; - /** Hashtag reference token (e.g. "#commit:abc123") for "New session referring to this". */ - hashtagRef?: string; - /** Callback invoked when the user picks "New session referring to this" from the context menu. */ - onNewSessionReferring?: (hashtagRef: string) => void; + /** Key used by the parent timeline context menu to look up row actions. */ + contextMenuKey?: string; } let { @@ -131,9 +124,7 @@ onDiscardChangesClick, discardChangesDisabledReason, showConnector = true, - commitSha, - hashtagRef, - onNewSessionReferring, + contextMenuKey, }: Props = $props(); let isNote = $derived( @@ -178,6 +169,16 @@ ); let isClickable = $derived(!!onItemClick && !isPending && !isFailed); let hasSession = $derived(!!sessionId && !deleting); + let pullTitle = $derived(pullDisabledReason ?? 'Pull'); + let pushTitle = $derived(pushDisabledReason ?? (pushing ? 'View push session' : 'Push')); + let forcePushTitle = $derived( + forcePushDisabledReason ?? + (forcePushing ? 'View push session' : 'Force push local branch to origin') + ); + let rebaseTitle = $derived(rebaseDisabledReason ?? 'Rebase'); + let commitChangesTitle = $derived(commitChangesDisabledReason ?? 'Commit changes'); + let discardChangesTitle = $derived(discardChangesDisabledReason ?? 'Discard changes'); + let deleteTitle = $derived(deleteDisabledReason ?? 'Delete'); function handleRowClick() { if (isClickable) { @@ -247,8 +248,13 @@ onDiscardChangesClick?.(); } - // ── Context menu ──────────────────────────────────────────────────── - let hasContextMenu = $derived(!!commitSha || (!!hashtagRef && !!onNewSessionReferring)); + function handleRowContextMenu(e: MouseEvent) { + if (!contextMenuKey) e.stopPropagation(); + } + + function handleRowPointerDown(e: PointerEvent) { + if (!contextMenuKey && e.pointerType !== 'mouse') e.stopPropagation(); + } {#snippet rowBody()} @@ -262,6 +268,9 @@ class:git-state={isGitState} class:compact={type === 'load-error'} onclick={handleRowClick} + oncontextmenu={handleRowContextMenu} + onpointerdown={handleRowPointerDown} + data-timeline-context-menu-key={contextMenuKey} >
{/if} {#if onPullClick || pullDisabledReason} - + {/if} {#if onCommitChangesClick || commitChangesDisabledReason} - + {/if} {#if onDeleteClick || deleteDisabledReason} - +
{/snippet} -{#if hasContextMenu} - - - {@render rowBody()} - - - {#if commitSha} - navigator.clipboard.writeText(commitSha!).catch(() => {})} - > - Copy SHA - - {/if} - {#if hashtagRef && onNewSessionReferring} - onNewSessionReferring!(hashtagRef!)}> - New session referring to this - - {/if} - - -{:else} - {@render rowBody()} -{/if} +{@render rowBody()}