diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e65ab274a..1538a7212 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1584,10 +1584,12 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} + {/* Why: workspace activation is a hot path; including activeWorktreeId + in reset keys remounts whole surfaces during wake. */} @@ -1692,7 +1694,7 @@ function App(): React.JSX.Element { @@ -1707,7 +1709,7 @@ function App(): React.JSX.Element { @@ -1756,7 +1758,7 @@ function App(): React.JSX.Element { @@ -1767,7 +1769,7 @@ function App(): React.JSX.Element { @@ -1791,16 +1793,15 @@ function App(): React.JSX.Element { - {/* Why: keep RightSidebar mounted even when closed so that its - child components (FileExplorer, SourceControl, etc.) and their - filesystem watchers + cached directory trees survive across - open/close toggles. Unmount on the tasks view since that - surface is intentionally distraction-free. */} + {/* Why: keep the right-sidebar shell mounted for layout stability. + Its heavy panels disconnect while closed so workspace wake stays + responsive. Unmount on the tasks view since that surface is + intentionally distraction-free. */} {showRightSidebarControls ? ( diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 990ecc355..7caa44fa3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -830,6 +830,17 @@ background: color-mix(in srgb, var(--sidebar-accent) 40%, transparent); } +[data-worktree-card-surface][data-worktree-card-active='true'] { + border-color: color-mix(in srgb, var(--sidebar-border) 40%, transparent); + background: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--sidebar-foreground) 4%, transparent); +} + +.dark [data-worktree-card-surface][data-worktree-card-active='true'] { + background: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--sidebar-foreground) 3%, transparent); +} + .worktree-agent-row-hover:hover { background: color-mix(in srgb, var(--sidebar-foreground) 1.25%, transparent); } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e76602fe1..4ed8f3e23 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -178,8 +178,11 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { } function Terminal(): React.JSX.Element | null { + const mountedWorktreeIdsRef = useRef(new Set()) + const measurableBackgroundWorktreeIdsRef = useRef(new Set()) const allWorktrees = useAllWorktrees() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const activeTabId = useAppStore((s) => s.activeTabId) @@ -227,7 +230,9 @@ function Terminal(): React.JSX.Element | null { const markFileDirty = useAppStore((s) => s.markFileDirty) const setTabBarOrder = useAppStore((s) => s.setTabBarOrder) const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree) - const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined + const tabBarOrder = renderedActiveWorktreeId + ? tabBarOrderByWorktree[renderedActiveWorktreeId] + : undefined // Why (anchored to selected thread, not active tab): the activity page // publishes the full {target, worktreeId, tabId} descriptor sourced from // its selectedThread. Deriving worktreeId/tabId from activeWorktreeId/ @@ -239,8 +244,8 @@ function Terminal(): React.JSX.Element | null { ) const tabs = useMemo( - () => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []), - [activeWorktreeId, tabsByWorktree] + () => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []), + [renderedActiveWorktreeId, tabsByWorktree] ) // Why: the TabBar is rendered into the titlebar via a portal so tabs share @@ -262,22 +267,22 @@ function Terminal(): React.JSX.Element | null { }, [activeWorktreeId, ensureWorktreeRootGroup]) // Filter editor files to only show those belonging to the active worktree - const worktreeFiles = activeWorktreeId - ? openFiles.filter((f) => f.worktreeId === activeWorktreeId) + const worktreeFiles = renderedActiveWorktreeId + ? openFiles.filter((f) => f.worktreeId === renderedActiveWorktreeId) : [] - const worktreeBrowserTabs = activeWorktreeId - ? (browserTabsByWorktree[activeWorktreeId] ?? []) + const worktreeBrowserTabs = renderedActiveWorktreeId + ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []) : [] const getEffectiveLayoutForWorktree = useCallback( (worktreeId: string) => getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree), [activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree] ) - const effectiveActiveLayout = activeWorktreeId - ? getEffectiveLayoutForWorktree(activeWorktreeId) + const effectiveActiveLayout = renderedActiveWorktreeId + ? getEffectiveLayoutForWorktree(renderedActiveWorktreeId) : undefined - const activeWorktreeBrowserTabIdsKey = activeWorktreeId - ? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',') + const activeWorktreeBrowserTabIdsKey = renderedActiveWorktreeId + ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []).map((tab) => tab.id).join(',') : '' // Save confirmation dialog state @@ -631,8 +636,6 @@ function Terminal(): React.JSX.Element | null { // Track which worktrees have been activated during this app session. // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. - const mountedWorktreeIdsRef = useRef(new Set()) - const measurableBackgroundWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeTimersRef = useRef(new Map()) const [, setBackgroundMountRevision] = useState(0) useEffect(() => { @@ -689,8 +692,8 @@ function Terminal(): React.JSX.Element | null { // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId // with ptyId: null, and TerminalPane would call connectPanePty → pty:spawn, // creating a duplicate PTY for the same tab. - if (activeWorktreeId && workspaceSessionReady) { - mountedWorktreeIdsRef.current.add(activeWorktreeId) + if (renderedActiveWorktreeId && workspaceSessionReady) { + mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) } // Prune IDs of worktrees that no longer exist (deleted/removed) const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id)) @@ -1547,12 +1550,12 @@ function Terminal(): React.JSX.Element | null { // because calling Zustand mutations during render interferes with React's // render cycle and causes blank screens when creating new tabs. useEffect(() => { - const activeWorktreeBrowserTabs = activeWorktreeId - ? (useAppStore.getState().browserTabsByWorktree[activeWorktreeId] ?? []) + const activeWorktreeBrowserTabs = renderedActiveWorktreeId + ? (useAppStore.getState().browserTabsByWorktree[renderedActiveWorktreeId] ?? []) : [] if ( activeTabType === 'browser' && - activeWorktreeId && + renderedActiveWorktreeId && (!activeBrowserTabId || !activeWorktreeBrowserTabs.some((tab) => tab.id === activeBrowserTabId)) ) { @@ -1565,7 +1568,7 @@ function Terminal(): React.JSX.Element | null { } }, [ activeTabType, - activeWorktreeId, + renderedActiveWorktreeId, activeBrowserTabId, activeWorktreeBrowserTabIdsKey, setActiveBrowserTab, @@ -1574,21 +1577,22 @@ function Terminal(): React.JSX.Element | null { return (
{/* Why: once split groups are enabled, each group owns its own tab strip inline. The old titlebar portal stays only as a fallback before the root-group layout has been established. */} - {activeWorktreeId && + {renderedActiveWorktreeId && !effectiveActiveLayout && titlebarTabsTarget && createPortal( { // Why: use strict equality with 'terminal' instead of !== 'settings' // so the terminal/browser surface hides on the tasks page too. - const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId + const isVisible = + activeView === 'terminal' && worktree.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) return ( @@ -1772,7 +1778,7 @@ function Terminal(): React.JSX.Element | null { // Why: use strict equality with 'terminal' instead of !== 'settings' // so browser panes also hide on the tasks page. const isVisibleWorktree = - activeView === 'terminal' && worktree.id === activeWorktreeId + activeView === 'terminal' && worktree.id === renderedActiveWorktreeId if (browserTabs.length === 0) { return null } @@ -1801,7 +1807,7 @@ function Terminal(): React.JSX.Element | null { })}
- {activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( + {renderedActiveWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index dec1c71c7..0bc3965c1 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Plug, Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react' import { useAppStore } from '@/store' -import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' import type { ActivityBarPosition } from '@/store/slices/editor' @@ -54,14 +54,18 @@ function RightSidebarInner(): React.JSX.Element { const sourceControlShortcut = useShortcutLabel('sidebar.sourceControl.toggle') const checksShortcut = useShortcutLabel('sidebar.checks.toggle') const portsShortcut = useShortcutLabel('sidebar.ports.toggle') - const activeWorktree = useActiveWorktree() const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) + const activeWorktree = useAppStore((s) => + rightSidebarOpen && s.activeWorktreeId + ? (s.getKnownWorktreeById(s.activeWorktreeId) ?? null) + : null + ) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth) const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) - const checksStatus = useAppStore(getActiveChecksStatus) + const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null)) const activityBarPosition = useAppStore((s) => s.activityBarPosition) const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) const [topActivityStripWidth, setTopActivityStripWidth] = useState(null) @@ -134,7 +138,7 @@ function RightSidebarInner(): React.JSX.Element { }) const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth) - const panelContent = ( + const panelContent = rightSidebarOpen ? (
{/* Why: sidebar panels no longer use key={activeWorktreeId} because the full unmount/remount cycle on every worktree switch triggered @@ -160,7 +164,7 @@ function RightSidebarInner(): React.JSX.Element { )}
- ) + ) : null const topActivityLayout = useMemo( () => getTopActivityBarLayout(visibleItems, topActivityStripWidth, effectiveTab), diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index aa76035d6..55ce2be1d 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -6,6 +6,7 @@ import { TopActivityOverflowMenu } from './activity-bar-buttons' import { RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME } from './right-sidebar-titlebar-drag-regions' const mockAppState = vi.hoisted(() => ({ + rightSidebarOpen: true, activityBarPosition: 'top' as 'top' | 'side' })) @@ -24,7 +25,7 @@ vi.mock('@/hooks/useShortcutLabel', () => ({ vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ - rightSidebarOpen: true, + rightSidebarOpen: mockAppState.rightSidebarOpen, rightSidebarWidth: 350, setRightSidebarWidth: vi.fn(), rightSidebarTab: 'explorer', @@ -130,6 +131,7 @@ function expectNoDrag(tag: string): void { describe('rendered right sidebar titlebar drag regions', () => { beforeEach(() => { + mockAppState.rightSidebarOpen = true mockAppState.activityBarPosition = 'top' }) @@ -188,4 +190,16 @@ describe('rendered right sidebar titlebar drag regions', () => { expectNoDrag(buttonOpeningTag(markup, 'Checks')) expect(buttonOpeningTag(markup, 'Toggle right sidebar')).toContain('sidebar-toggle') }) + + it('does not render hidden panel content while the sidebar is closed', () => { + mockAppState.rightSidebarOpen = false + + const markup = renderToStaticMarkup() + + expect(markup).not.toContain('data-file-explorer') + expect(markup).not.toContain('data-source-control') + expect(markup).not.toContain('data-search-panel') + expect(markup).not.toContain('data-checks-panel') + expect(markup).not.toContain('data-ports-panel') + }) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index ec4b3a907..b7dce278b 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' -import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/store/selectors' +import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors' import type { GitConflictOperation } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' @@ -13,9 +13,9 @@ import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' const POLL_INTERVAL_MS = 3000 export function useGitStatusPolling(): void { - const activeWorktree = useActiveWorktree() - const allWorktrees = useAllWorktrees() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activeWorktree = useWorktreeById(activeWorktreeId) + const allWorktrees = useAllWorktrees() const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) const setGitStatus = useAppStore((s) => s.setGitStatus) const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2c8ab90fe..56ad15f3c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -22,7 +22,7 @@ import { SshDisconnectedDialog } from './SshDisconnectedDialog' import WorktreeCardAgents from './WorktreeCardAgents' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { @@ -71,8 +71,12 @@ type WorktreeCardProps = { lineageChildren?: React.ReactNode onLineageToggle?: (event: React.MouseEvent) => void onActivate?: () => void + onImmediateActivate?: (worktreeId: string) => void onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean - onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] + onContextMenuSelect?: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] onCardDragStart?: ( event: React.DragEvent, worktreeId: string, @@ -103,6 +107,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone = 'default', selectedWorktrees, onActivate, + onImmediateActivate, onSelectionGesture, onContextMenuSelect, onCardDragStart, @@ -400,13 +405,14 @@ const WorktreeCard = React.memo(function WorktreeCard({ // Why: route sidebar clicks through the shared activation path so the // back/forward stack stays complete for the primary worktree navigation // surface instead of only recording palette-driven switches. - activateAndRevealWorktree(worktree.id) + onImmediateActivate?.(worktree.id) + activateWorktreeFromSidebar(worktree.id) if (isSshDisconnected) { setShowDisconnectedDialog(true) } onActivate?.() }, - [worktree.id, isSshDisconnected, onActivate, onSelectionGesture] + [worktree.id, isSshDisconnected, onActivate, onImmediateActivate, onSelectionGesture] ) const handleRenameTitle = useCallback( @@ -482,6 +488,11 @@ const WorktreeCard = React.memo(function WorktreeCard({ [isDeleting, isMultiSelected, onCardDragStart, selectedWorktrees, worktree.id] ) + const handleContextMenuSelect = useCallback( + (event: React.MouseEvent) => onContextMenuSelect?.(event, worktree) ?? [worktree], + [onContextMenuSelect, worktree] + ) + const stopQuickActionPointerPropagation = useCallback( (event: React.PointerEvent) => { // Why: the Kanban board is dismissed by document-level pointer handling. @@ -680,6 +691,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ isDeleting && 'opacity-50 grayscale cursor-not-allowed', isSshDisconnected && !isDeleting && 'opacity-60' )} + data-worktree-card-surface="true" + data-worktree-card-active={isActiveSurface ? 'true' : undefined} onClick={handleClick} onDoubleClick={handleDoubleClick} draggable={nativeDragEnabled && !isDeleting && !titleRenaming} @@ -974,7 +987,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ {cardBody} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index aa916ce5b..1a9499fd0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' import { measureElement as measureVirtualElementSize, useVirtualizer @@ -19,6 +20,7 @@ import { Workflow } from 'lucide-react' import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import { getAllWorktreesFromState, useAllWorktrees, @@ -64,7 +66,6 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { - type GroupHeaderRow, type ProjectGroupOrdering, type Row, type WorktreeGroupBy, @@ -103,14 +104,17 @@ import { } from './visible-worktrees' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs, + getWorktreeSectionTerminalActivityTabs } from './visible-worktree-activity-inputs' +import { selectTerminalLayoutRootsForWorktrees } from './worktree-card-status-inputs' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, useVirtualizedScrollAnchor, type VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT } from '@/lib/scroll-to-current-workspace-status' import { useRepoHeaderDrag } from './project-header-drag' @@ -205,6 +209,9 @@ type ProjectGroupDeleteDialogState = { const SORT_SETTLE_MS = 3_000 const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] +const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // Why: TanStack Virtual owns scroll correction. Native browser anchoring can @@ -277,6 +284,35 @@ function getWorktreeOptionId(worktreeId: string): string { return `worktree-list-option-${encodeURIComponent(worktreeId)}` } +function markSidebarWorktreeActiveImmediately(worktreeId: string): void { + const nextOption = document.getElementById(getWorktreeOptionId(worktreeId)) + if (!nextOption) { + return + } + + const sidebar = + nextOption.closest('[data-worktree-sidebar]') ?? + document.querySelector('[data-worktree-sidebar]') + const previousOption = sidebar?.querySelector('[role="option"][aria-current="page"]') + if (previousOption && previousOption !== nextOption) { + previousOption.removeAttribute('aria-current') + } + + nextOption.setAttribute('aria-current', 'page') + sidebar + ?.querySelectorAll( + '[data-worktree-card-surface][data-worktree-card-active="true"]' + ) + .forEach((surface) => { + if (!nextOption.contains(surface)) { + surface.removeAttribute('data-worktree-card-active') + } + }) + nextOption + .querySelector('[data-worktree-card-surface]') + ?.setAttribute('data-worktree-card-active', 'true') +} + function revealMountedWorktreeElement( container: HTMLElement, worktreeId: string, @@ -333,6 +369,7 @@ type VirtualizedWorktreeViewportProps = { selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onImmediateWorktreeActivate: (worktreeId: string) => void onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -674,6 +711,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktreeIds, selectedWorktrees, onSelectionGesture, + onImmediateWorktreeActivate, onContextMenuSelect, repoMap, worktreeMap, @@ -2882,8 +2920,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp nativeDragEnabled={false} contentIndent={nested ? 0 : paddingLeft} flushSurface={!nested} + onImmediateActivate={onImmediateWorktreeActivate} onSelectionGesture={onSelectionGesture} - onContextMenuSelect={(event) => onContextMenuSelect(event, itemRow.worktree)} + onContextMenuSelect={onContextMenuSelect} onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} @@ -2915,7 +2954,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (selectionOnly) { return } - activateAndRevealWorktree(child.worktree.id) + onImmediateWorktreeActivate(child.worktree.id) + activateWorktreeFromSidebar(child.worktree.id) if (child.repo?.connectionId) { const sshStatus = useAppStore.getState().sshConnectionStates.get(child.repo.connectionId) @@ -2941,6 +2981,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp role="option" aria-selected={selectedWorktreeIds.has(child.worktree.id)} aria-current={isActive ? 'page' : undefined} + data-worktree-card-surface="true" + data-worktree-card-active={isActive ? 'true' : undefined} className={cn( 'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors', highlightedRevealWorktreeId === child.worktree.id && [ @@ -3188,6 +3230,7 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) @@ -3208,10 +3251,20 @@ const WorktreeList = React.memo(function WorktreeList({ const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - const agentTargetStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const agentTargetStatusEpoch = useAppStore((s) => s.agentStatusEpoch) - const agentTargetTabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const agentTargetTerminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + // Why: agent-send eligibility only matters while the picker is open. When it + // is closed, avoid subscribing WorktreeList to wake-time terminal layout churn. + const agentTargetStatusByPaneKey = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY + ) + const agentTargetStatusEpoch = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 + ) + const agentTargetTabsByWorktree = useAppStore((s) => + agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE + ) + const agentTargetTerminalLayoutsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID + ) const agentSendTargetWorktreeId = useMemo(() => { void agentTargetStatusEpoch if (!agentSendPopoverTargetMode) { @@ -3256,11 +3309,14 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null ) const settings = useAppStore((s) => s.settings) - const sectionActivityTabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const sectionActivityBrowserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) + const sectionActivityTabsByWorktree = useAppStore((s) => + getWorktreeSectionTerminalActivityTabs(s.tabsByWorktree) + ) + const sectionActivityBrowserTabsByWorktree = useAppStore((s) => + getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) + ) const sectionActivityPtyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const sectionActivityRuntimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const sectionActivityTerminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const sectionActivityAgentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const sectionActivityMigrationUnsupportedByPtyId = useAppStore( (s) => s.migrationUnsupportedByPtyId @@ -3546,6 +3602,13 @@ const WorktreeList = React.memo(function WorktreeList({ ]) const worktrees = visibleWorktrees + const sectionActivityWorktreeIds = useMemo( + () => visibleWorktrees.map((worktree) => worktree.id), + [visibleWorktrees] + ) + const sectionActivityTerminalLayoutRootsByTabId = useAppStore( + useShallow((s) => selectTerminalLayoutRootsForWorktrees(s, sectionActivityWorktreeIds)) + ) const collapsedGroups = useAppStore((s) => s.collapsedGroups) const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) @@ -3652,7 +3715,7 @@ const WorktreeList = React.memo(function WorktreeList({ browserTabsByWorktree: sectionActivityBrowserTabsByWorktree, ptyIdsByTabId: sectionActivityPtyIdsByTabId, runtimePaneTitlesByTabId: sectionActivityRuntimePaneTitlesByTabId, - terminalLayoutsByTabId: sectionActivityTerminalLayoutsByTabId, + terminalLayoutRootsByTabId: sectionActivityTerminalLayoutRootsByTabId, agentStatusEpoch: sectionActivityAgentStatusEpoch, // Why: agentStatusByPaneKey can tick for same-state tool details. The // section counts only need structural status transitions, tracked by @@ -3667,8 +3730,8 @@ const WorktreeList = React.memo(function WorktreeList({ sectionActivityMigrationUnsupportedByPtyId, sectionActivityPtyIdsByTabId, sectionActivityRetainedAgentsByPaneKey, + sectionActivityTerminalLayoutRootsByTabId, sectionActivityRuntimePaneTitlesByTabId, - sectionActivityTerminalLayoutsByTabId, sectionActivityTabsByWorktree ]) const sectionActivityByGroupKey = useMemo( @@ -3735,17 +3798,9 @@ const WorktreeList = React.memo(function WorktreeList({ importedWorktreesByRepo ] ) - // Why: header/mode changes can shift entire groups, so remount the - // virtualizer for those broad structure changes. Do not key on rows.length: - // add/delete must keep the same row DOM long enough for the remaining rows - // to animate upward and for the scroll anchor to hold the viewport steady. - const viewportResetKey = useMemo(() => { - const headers = rows - .filter((r): r is GroupHeaderRow => r.type === 'header') - .map((r) => r.key) - .join(',') - return `${groupBy}:lineage:${headers}` - }, [groupBy, rows]) + // Why: status headers change during wake (inactive -> active). Key only on + // the grouping mode so row identity survives those ordinary status moves. + const viewportResetKey = `group:${groupBy}:lineage` // Why: derive the rendered item order from the post-buildRows() row list, // not the flat `worktrees` array, because grouping (groupBy: 'repo' or @@ -3839,10 +3894,17 @@ const WorktreeList = React.memo(function WorktreeList({ [selectedWorktreeIds, selectedWorktrees] ) + const handleImmediateWorktreeActivate = useCallback((worktreeId: string) => { + // Why: React-rendering the full virtualized sidebar on the pointer path is + // visible latency. Mutate only the selected-row affordance; store state + // reconciles the same attributes after activation settles. + markSidebarWorktreeActiveImmediately(worktreeId) + }, []) + // Why: full-page navigation views are not scoped to one worktree, so no // sidebar card should appear selected while one of them is active. const selectedSidebarWorktreeId = - activeView === 'tasks' || activeView === 'activity' ? null : activeWorktreeId + activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId // Why layout effect instead of effect: the global Cmd/Ctrl+1–9 key handler // can fire immediately after React commits the new grouped/collapsed order. @@ -4245,7 +4307,7 @@ const WorktreeList = React.memo(function WorktreeList({ key={viewportResetKey} rows={rows} activeWorktreeId={selectedSidebarWorktreeId} - currentWorktreeId={activeWorktreeId} + currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} projectGroupOrdering={projectGroupOrdering} toggleGroup={toggleGroup} @@ -4270,6 +4332,7 @@ const WorktreeList = React.memo(function WorktreeList({ selectedWorktreeIds={selectedWorktreeIds} selectedWorktrees={selectedWorktrees} onSelectionGesture={updateSelectionForGesture} + onImmediateWorktreeActivate={handleImmediateWorktreeActivate} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} worktreeMap={worktreeMap} diff --git a/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts b/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts index f98099d68..b1ccde050 100644 --- a/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/components/sidebar/runtime-pane-title-leaf-id.ts @@ -40,6 +40,13 @@ function collectLeafIdsInReplayCreationOrder( export function resolveRuntimePaneTitleLeafId( tabLayout: TerminalLayoutSnapshot | undefined, runtimePaneId: string +): string | null { + return resolveRuntimePaneTitleLeafIdFromRoot(tabLayout?.root, runtimePaneId) +} + +export function resolveRuntimePaneTitleLeafIdFromRoot( + root: TerminalPaneLayoutNode | null | undefined, + runtimePaneId: string ): string | null { if (isTerminalLeafId(runtimePaneId)) { return runtimePaneId @@ -48,6 +55,6 @@ export function resolveRuntimePaneTitleLeafId( if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) { return null } - const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.root) + const leafIds = collectLeafIdsInReplayCreationOrder(root) return leafIds[numericPaneId - FIRST_PANE_ID] ?? null } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index ea7b76702..3abec4275 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -5,9 +5,9 @@ import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-statu import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers' import { selectLivePtyIdsForWorktree, + selectTerminalLayoutRootsForWorktree, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' -import { selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { @@ -19,8 +19,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const ptyIdsForWorktree = useAppStore( useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId)) ) - const terminalLayoutsByTabId = useAppStore( - useShallow((s) => selectTerminalLayoutsForWorktree(s, worktreeId)) + const terminalLayoutRootsByTabId = useAppStore( + useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId)) ) const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone, freshHookLeafIdsByTabId } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) @@ -36,7 +36,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ptyIdsByTabId: ptyIdsForWorktree, runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, freshHookLeafIdsByTabId, - terminalLayoutsByTabId, + terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, hasLiveDone, @@ -48,7 +48,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ptyIdsForWorktree, runtimePaneTitlesForWorktree, freshHookLeafIdsByTabId, - terminalLayoutsByTabId, + terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, hasLiveDone, diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts index c96aa38d5..6012330e5 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs, + getWorktreeSectionTerminalActivityTabs } from './visible-worktree-activity-inputs' function terminalTab(id: string, title: string): TerminalTab { @@ -94,6 +95,40 @@ describe('visible worktree activity inputs', () => { expect(second['wt-1']).toBe(first['wt-1']) }) + it('preserves section terminal projection when only wake bookkeeping changes', () => { + const first = getWorktreeSectionTerminalActivityTabs({ + 'wt-1': [terminalTab('tab-1', 'Codex working')] + }) + + const second = getWorktreeSectionTerminalActivityTabs({ + 'wt-1': [ + { + ...terminalTab('tab-1', 'Codex working'), + generation: 2, + pendingActivationSpawn: true + } + ] + }) + + // Why: slept-workspace wake updates generation/pendingActivationSpawn to + // remount terminal panes; collapsed section dots only need id + title. + expect(second).toBe(first) + expect(second['wt-1']).toBe(first['wt-1']) + }) + + it('updates section terminal projection when terminal title changes', () => { + const first = getWorktreeSectionTerminalActivityTabs({ + 'wt-1': [terminalTab('tab-1', 'zsh')] + }) + + const second = getWorktreeSectionTerminalActivityTabs({ + 'wt-1': [terminalTab('tab-1', 'Codex working')] + }) + + expect(second).not.toBe(first) + expect(second['wt-1']).toEqual([{ id: 'tab-1', title: 'Codex working' }]) + }) + it('preserves browser activity projection when only browser metadata changes', () => { const first = getVisibleWorktreeBrowserActivityTabs({ 'wt-1': [browserTab('browser-1', 'First')] diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index d3ca44165..ff81a9e20 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,26 +1,31 @@ import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' -type TerminalActivityTab = Pick -type BrowserActivityTab = Pick +export type TerminalActivityTab = Pick +export type BrowserActivityTab = Pick +export type WorktreeSectionTerminalActivityTab = Pick -function haveSameIds( - previous: readonly T[] | undefined, - next: readonly { id: string }[] +function haveSameProjection( + previous: readonly U[] | undefined, + next: readonly T[], + isSame: (previousTab: U, nextTab: T) => boolean ): boolean { if (!previous || previous.length !== next.length) { return false } for (let index = 0; index < next.length; index++) { - if (previous[index]?.id !== next[index]?.id) { + const previousTab = previous[index] + if (!previousTab || !isSame(previousTab, next[index])) { return false } } return true } -function projectIdTabs( +function projectTabs( tabsByWorktree: Record, - previousProjection: Record | null + previousProjection: Record | null, + projectTab: (tab: T) => U, + isSame: (previousTab: U, nextTab: T) => boolean ): { projection: Record; unchanged: boolean } { const nextProjection: Record = {} let unchanged = @@ -29,17 +34,29 @@ function projectIdTabs( for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { const previousTabs = previousProjection?.[worktreeId] - if (haveSameIds(previousTabs, tabs)) { + if (haveSameProjection(previousTabs, tabs, isSame)) { nextProjection[worktreeId] = previousTabs as U[] continue } unchanged = false - nextProjection[worktreeId] = tabs.map((tab) => ({ id: tab.id }) as U) + nextProjection[worktreeId] = tabs.map(projectTab) } return { projection: nextProjection, unchanged } } +function projectIdTabs( + tabsByWorktree: Record, + previousProjection: Record | null +): { projection: Record; unchanged: boolean } { + return projectTabs( + tabsByWorktree, + previousProjection, + (tab) => ({ id: tab.id }) as U, + (previousTab, nextTab) => previousTab.id === nextTab.id + ) +} + let cachedTerminalSource: Record | null = null let cachedTerminalProjection: Record | null = null @@ -58,6 +75,30 @@ export function getVisibleWorktreeTerminalActivityTabs( return projection } +let cachedSectionTerminalSource: Record | null = null +let cachedSectionTerminalProjection: Record | null = + null + +export function getWorktreeSectionTerminalActivityTabs( + tabsByWorktree: Record +): Record { + if (cachedSectionTerminalSource === tabsByWorktree && cachedSectionTerminalProjection) { + return cachedSectionTerminalProjection + } + const { projection, unchanged } = projectTabs( + tabsByWorktree, + cachedSectionTerminalProjection, + (tab) => ({ id: tab.id, title: tab.title }), + (previousTab, nextTab) => previousTab.id === nextTab.id && previousTab.title === nextTab.title + ) + cachedSectionTerminalSource = tabsByWorktree + if (unchanged && cachedSectionTerminalProjection) { + return cachedSectionTerminalProjection + } + cachedSectionTerminalProjection = projection + return projection +} + let cachedBrowserSource: Record | null = null let cachedBrowserProjection: Record | null = null diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 68c7e6f9c..c48cdb9ac 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -25,17 +25,20 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { freshHookLeafIdsByTabId: EMPTY_HOOK_LEAF_IDS_BY_TAB_ID } +type AgentActivityTabsByWorktree = Record + export type AgentActivityInput = Pick< AppState, - | 'tabsByWorktree' | 'agentStatusEpoch' | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' -> +> & { + tabsByWorktree: AgentActivityTabsByWorktree +} type AgentActivityCache = { - tabsByWorktree: AppState['tabsByWorktree'] + tabsByWorktree: AgentActivityTabsByWorktree agentStatusEpoch: number migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index ce6aa8f79..0030e7d93 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import type { TerminalTab } from '../../../../shared/types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab +} from '../../../../shared/types' import { selectLivePtyIdsForWorktree, + selectTerminalLayoutRootsForWorktree, + selectTerminalLayoutRootsForWorktrees, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' type SelectorState = Parameters[0] +type LayoutRootSelectorState = Parameters[0] function makeTab(id: string, worktreeId: string): TerminalTab { return { @@ -21,6 +28,15 @@ function makeTab(id: string, worktreeId: string): TerminalTab { } } +function makeLayout(root: TerminalPaneLayoutNode, ptyId: string): TerminalLayoutSnapshot { + return { + root, + activeLeafId: root.type === 'leaf' ? root.leafId : null, + expandedLeafId: null, + ptyIdsByLeafId: root.type === 'leaf' ? { [root.leafId]: ptyId } : {} + } +} + describe('worktree card status input selectors', () => { it('stays shallow-equal when unrelated tabs receive PTY ids or pane titles', () => { const worktreeId = 'repo1::/path/wt1' @@ -91,4 +107,42 @@ describe('worktree card status input selectors', () => { ) ).toBe(false) }) + + it('stays shallow-equal when wake updates only PTY bindings inside terminal layouts', () => { + const worktreeId = 'repo1::/path/wt1' + const root: TerminalPaneLayoutNode = { + type: 'leaf', + leafId: '11111111-1111-4111-8111-111111111111' + } + const state: LayoutRootSelectorState = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)] + }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout(root, 'pty-before') + } + } + const wakeBindingUpdate: LayoutRootSelectorState = { + ...state, + terminalLayoutsByTabId: { + 'tab-1': makeLayout(root, 'pty-after') + } + } + + // Why: waking a slept pane rewrites ptyIdsByLeafId several times. Status + // heuristics only need the layout root, so binding-only churn should not + // invalidate every sidebar card or section summary. + expect( + shallow( + selectTerminalLayoutRootsForWorktree(state, worktreeId), + selectTerminalLayoutRootsForWorktree(wakeBindingUpdate, worktreeId) + ) + ).toBe(true) + expect( + shallow( + selectTerminalLayoutRootsForWorktrees(state, [worktreeId]), + selectTerminalLayoutRootsForWorktrees(wakeBindingUpdate, [worktreeId]) + ) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index d0552e89f..e06894441 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,12 +1,16 @@ import type { AppState } from '@/store/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/types' // Why: these selectors return fresh maps whose top-level values preserve // underlying per-tab references, so callers must compare them shallowly. -type WorktreeCardStatusInputState = Pick< - AppState, - 'tabsByWorktree' | 'runtimePaneTitlesByTabId' | 'ptyIdsByTabId' -> +type WorktreeCardStatusInputState = Pick & { + tabsByWorktree: Record +} + +type WorktreeCardLayoutRootInputState = Pick & { + tabsByWorktree: Record +} export function selectRuntimePaneTitlesForWorktree( state: WorktreeCardStatusInputState, @@ -35,3 +39,27 @@ export function selectLivePtyIdsForWorktree( } return out } + +export function selectTerminalLayoutRootsForWorktree( + state: WorktreeCardLayoutRootInputState, + worktreeId: string +): Record { + const out: Record = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root + } + return out +} + +export function selectTerminalLayoutRootsForWorktrees( + state: WorktreeCardLayoutRootInputState, + worktreeIds: readonly string[] +): Record { + const out: Record = {} + for (const worktreeId of worktreeIds) { + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root + } + } + return out +} diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts index 0923c8147..0baedd556 100644 --- a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts +++ b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts @@ -83,7 +83,7 @@ function makeState( browserTabsByWorktree: {}, ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, - terminalLayoutsByTabId: {}, + terminalLayoutRootsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts index 1f4c2cf69..f5e7f65b9 100644 --- a/src/renderer/src/components/sidebar/worktree-section-activity.ts +++ b/src/renderer/src/components/sidebar/worktree-section-activity.ts @@ -3,6 +3,8 @@ import { resolveWorktreeStatus } from '@/lib/worktree-status' import type { ProjectGroup, Repo, + TerminalPaneLayoutNode, + TerminalTab, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' @@ -15,21 +17,22 @@ import { selectLivePtyIdsForWorktree, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' -import { selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' +import type { BrowserActivityTab } from './visible-worktree-activity-inputs' export type WorktreeSectionActivityState = Pick< AppState, - | 'tabsByWorktree' - | 'browserTabsByWorktree' | 'ptyIdsByTabId' | 'runtimePaneTitlesByTabId' - | 'terminalLayoutsByTabId' | 'agentStatusEpoch' | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' -> +> & { + tabsByWorktree: Record[]> + browserTabsByWorktree: Record + terminalLayoutRootsByTabId: Record +} export type WorktreeSectionActivitySummary = { runningCount: number @@ -103,7 +106,7 @@ function getSectionWorktreeStatus( ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), freshHookLeafIdsByTabId: agentSummary.freshHookLeafIdsByTabId, - terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(state, worktreeId), + terminalLayoutRootsByTabId: state.terminalLayoutRootsByTabId, hasPermission: agentSummary.hasPermission, hasLiveWorking: agentSummary.hasLiveWorking, hasLiveDone: agentSummary.hasLiveDone, diff --git a/src/renderer/src/lib/input-quiet-scheduler.ts b/src/renderer/src/lib/input-quiet-scheduler.ts new file mode 100644 index 000000000..7e251ee68 --- /dev/null +++ b/src/renderer/src/lib/input-quiet-scheduler.ts @@ -0,0 +1,122 @@ +type InputQuietScheduleOptions = { + delayMs: number + quietMs: number + idleTimeoutMs: number +} + +const INPUT_QUIET_EVENTS: readonly (keyof WindowEventMap)[] = [ + 'keydown', + 'pointerdown', + 'pointermove', + 'pointerup', + 'touchstart', + 'wheel' +] + +let listenersInstalled = false +let lastInputAt = 0 + +function now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now() +} + +function recordInput(): void { + lastInputAt = now() +} + +export function markInputQuietSchedulerInput(): void { + recordInput() +} + +function ensureInputQuietListeners(targetWindow: Window): void { + if (listenersInstalled) { + return + } + listenersInstalled = true + lastInputAt = now() + const options: AddEventListenerOptions = { capture: true, passive: true } + for (const eventName of INPUT_QUIET_EVENTS) { + targetWindow.addEventListener(eventName, recordInput, options) + } +} + +function scheduleIdleCallback(targetWindow: Window, callback: () => void, timeout: number): number { + const schedulerWindow = targetWindow as Window & { + requestIdleCallback?: Window['requestIdleCallback'] + } + if (typeof schedulerWindow.requestIdleCallback === 'function') { + return schedulerWindow.requestIdleCallback(callback, { timeout }) + } + return targetWindow.setTimeout(callback, 0) +} + +function cancelIdleCallback(targetWindow: Window, idleId: number): void { + const schedulerWindow = targetWindow as Window & { + cancelIdleCallback?: Window['cancelIdleCallback'] + } + if (typeof schedulerWindow.cancelIdleCallback === 'function') { + schedulerWindow.cancelIdleCallback(idleId) + return + } + targetWindow.clearTimeout(idleId) +} + +export function scheduleAfterInputQuiet( + callback: () => void, + { delayMs, quietMs, idleTimeoutMs }: InputQuietScheduleOptions +): () => void { + if (typeof window === 'undefined') { + const fallbackTimer = setTimeout(callback, delayMs) + return () => clearTimeout(fallbackTimer) + } + + const targetWindow = window + ensureInputQuietListeners(targetWindow) + + let cancelled = false + let delayTimer: number | null = null + let quietTimer: number | null = null + let idleId: number | null = null + + const run = (): void => { + idleId = null + if (!cancelled) { + callback() + } + } + + const checkQuietWindow = (): void => { + quietTimer = null + if (cancelled) { + return + } + const inputQuietForMs = now() - lastInputAt + const remainingQuietMs = quietMs - inputQuietForMs + if (remainingQuietMs > 0) { + quietTimer = targetWindow.setTimeout(checkQuietWindow, remainingQuietMs) + return + } + idleId = scheduleIdleCallback(targetWindow, run, idleTimeoutMs) + } + + // Why: terminal wake remounts xterm panes. Wait for both the initial delay + // and a quiet input window so a follow-up click/keystroke cannot collide + // with the heavy remount. + delayTimer = targetWindow.setTimeout(() => { + delayTimer = null + checkQuietWindow() + }, delayMs) + + return () => { + cancelled = true + if (delayTimer !== null) { + targetWindow.clearTimeout(delayTimer) + } + if (quietTimer !== null) { + targetWindow.clearTimeout(quietTimer) + } + if (idleId !== null) { + cancelIdleCallback(targetWindow, idleId) + } + } +} diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts new file mode 100644 index 000000000..0710c064f --- /dev/null +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -0,0 +1,56 @@ +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' + +const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450 +const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120 + +let pendingSidebarWorktreeActivation: { + worktreeId: string + cancel: () => void +} | null = null + +function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean { + const state = useAppStore.getState() + const tabs = state.tabsByWorktree[worktreeId] ?? [] + if (tabs.length === 0) { + return false + } + if ((state.browserTabsByWorktree[worktreeId] ?? []).length > 0) { + return false + } + if (state.openFiles.some((file) => file.worktreeId === worktreeId)) { + return false + } + return tabs.every((tab) => !tabHasLivePty(state.ptyIdsByTabId, tab.id)) +} + +export function activateWorktreeFromSidebar(worktreeId: string): void { + pendingSidebarWorktreeActivation?.cancel() + pendingSidebarWorktreeActivation = null + + const activate = (): void => { + if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) { + pendingSidebarWorktreeActivation = null + } + activateAndRevealWorktree(worktreeId) + } + + if (!shouldDeferSidebarWorktreeActivation(worktreeId)) { + activate() + return + } + + markInputQuietSchedulerInput() + // Why: a slept workspace may remount terminals. Keep that work cancellable so + // a quick "changed my mind" click is never queued behind the first wake. + pendingSidebarWorktreeActivation = { + worktreeId, + cancel: scheduleAfterInputQuiet(activate, { + delayMs: 0, + quietMs: SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS, + idleTimeoutMs: SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS + }) + } +} diff --git a/src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts b/src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts new file mode 100644 index 000000000..fec3e3280 --- /dev/null +++ b/src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalPaneLayoutNode } from '../../../shared/types' +import { resolveWorktreeStatus } from './worktree-status' + +const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' + +function splitLayoutRoot(): TerminalPaneLayoutNode { + return { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID_1 }, + second: { type: 'leaf', leafId: LEAF_ID_2 } + } +} + +describe('resolveWorktreeStatus terminal layout roots', () => { + it('suppresses stale working titles without full layout snapshots', () => { + const status = resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: 'claude [working]' }], + browserTabs: [], + ptyIdsByTabId: { 'tab-1': ['pty-0'] }, + runtimePaneTitlesByTabId: { + 'tab-1': { + 1: 'codex [working]', + 2: 'bash' + } + }, + freshHookLeafIdsByTabId: { + 'tab-1': new Set([LEAF_ID_1]) + }, + terminalLayoutRootsByTabId: { + 'tab-1': splitLayoutRoot() + }, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: true, + hasRetainedDone: false + }) + + expect(status).toBe('done') + }) +}) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 95a5bb150..581329cf7 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,13 +1,18 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { resolveRuntimePaneTitleLeafId } from '@/components/sidebar/runtime-pane-title-leaf-id' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' +import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/components/sidebar/runtime-pane-title-leaf-id' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab +} from '../../../shared/types' export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' type WorktreeStatusHeuristicOptions = { freshHookLeafIdsByTabId?: Record> terminalLayoutsByTabId?: Record + terminalLayoutRootsByTabId?: Record } const STATUS_LABELS: Record = { @@ -19,8 +24,8 @@ const STATUS_LABELS: Record = { } export function getWorktreeStatus( - tabs: Pick[], - browserTabs: { id: string }[], + tabs: readonly Pick[], + browserTabs: readonly { id: string }[], ptyIdsByTabId: Record, runtimePaneTitlesByTabId: Record> = {}, options: WorktreeStatusHeuristicOptions = {} @@ -69,10 +74,11 @@ function tabHasStatus( const hookLeafIds = options.freshHookLeafIdsByTabId?.[tab.id] const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { - const tabLayout = options.terminalLayoutsByTabId?.[tab.id] + const tabLayoutRoot = + options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root const paneTitleEntries = Object.entries(paneTitles) for (const [runtimePaneId, title] of paneTitleEntries) { - const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) + const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) // Why: runtime titles can arrive before layout hydration in SSH/replay // paths. With exactly one title and one hook leaf, the tab is // unambiguous enough to prefer hook authority over a stale spinner. @@ -123,12 +129,13 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { * - `hasRetainedDone`: any retained-agent snapshot scoped to this worktreeId. */ export function resolveWorktreeStatus(args: { - tabs: Pick[] - browserTabs: { id: string }[] + tabs: readonly Pick[] + browserTabs: readonly { id: string }[] ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record> freshHookLeafIdsByTabId?: Record> terminalLayoutsByTabId?: Record + terminalLayoutRootsByTabId?: Record hasPermission: boolean hasLiveWorking: boolean hasLiveDone: boolean @@ -141,7 +148,8 @@ export function resolveWorktreeStatus(args: { args.runtimePaneTitlesByTabId ?? {}, { freshHookLeafIdsByTabId: args.freshHookLeafIdsByTabId, - terminalLayoutsByTabId: args.terminalLayoutsByTabId + terminalLayoutsByTabId: args.terminalLayoutsByTabId, + terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId } ) if (args.hasPermission) { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 07e773e2b..39005b286 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -35,11 +35,16 @@ import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { branchName } from '@/lib/git-utils' +import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list // UI hydration parity this slice used before `worktree.detectedList` existed. const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 +const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 +const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 +const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 +const pendingActivationTerminalPrepCancels = new Map void>() function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { if (!node) { @@ -60,6 +65,10 @@ function getActivationSpawnSuppression(layout: TerminalLayoutSnapshot | undefine return paneCount === 1 ? true : paneCount } +function shouldDeferActivationTerminalPrep(): boolean { + return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' +} + function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefined): void { if (!result || result.status === 'updated') { return @@ -1957,6 +1966,10 @@ export const createWorktreeSlice: StateCreator }, setActiveWorktree: (worktreeId) => { + if (worktreeId && shouldDeferActivationTerminalPrep()) { + markInputQuietSchedulerInput() + } + if (get().activeWorktreeId !== worktreeId) { moveFocusToRendererBeforeFocusedWebviewHidden() } @@ -1964,6 +1977,8 @@ export const createWorktreeSlice: StateCreator ? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId : null let shouldClearUnread = false + let shouldPrepareTerminalTabs = false + let shouldTagTerminalTabs = false set((s) => { if (!worktreeId) { return { @@ -2089,12 +2104,9 @@ export const createWorktreeSlice: StateCreator // PTY lifecycle and explicit edits still flow through bumpWorktreeActivity. const metaUpdates: Partial = shouldClearUnread ? { isUnread: false } : {} - // Why: the generation bump for dead-PTY tabs MUST happen in the same - // set() as the activation. Two separate set() calls let React/Zustand - // render the old (dead-transport) TerminalPane as visible for one frame - // before the generation bump unmounts it — that intermediate render - // resumes the pane with a transport stuck at connected=false/ptyId=null, - // and user input is silently dropped. + // Why: dead-PTY terminal prep must complete before the workspace shell + // renders that tab. The shell render is deferred below, so terminal prep + // can wait for input quiet instead of blocking the activation click. // // Why pendingActivationSpawn + first-activation check: the first time a // worktree is activated in this session, its TerminalPane mounts and @@ -2119,32 +2131,13 @@ export const createWorktreeSlice: StateCreator tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) const isFirstActivation = worktreeId != null && !s.everActivatedWorktreeIds.has(worktreeId) const shouldTagTabs = worktreeId != null && tabs.length > 0 && isFirstActivation + shouldPrepareTerminalTabs = Boolean( + worktreeId && tabs.length > 0 && (allDead || shouldTagTabs) + ) + shouldTagTerminalTabs = shouldTagTabs const nextEverActivated = isFirstActivation ? new Set([...s.everActivatedWorktreeIds, worktreeId!]) : s.everActivatedWorktreeIds - const tabsByWorktreeUpdate = - allDead || shouldTagTabs - ? { - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId!]: tabs.map((tab) => ({ - ...tab, - ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), - // Why: the allDead generation bump remounts panes and may - // fresh-spawn PTYs — click side-effects, not real activity. - // Split layouts remount several panes, so count the expected - // pane events instead of suppressing only the first one. - ...(allDead || shouldTagTabs - ? { - pendingActivationSpawn: getActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - } - : {}) - })) - } - } - : {} const nextWorktrees = shouldClearUnread ? applyWorktreeUpdates(s.worktreesByRepo, worktreeId, metaUpdates) : s.worktreesByRepo @@ -2163,11 +2156,60 @@ export const createWorktreeSlice: StateCreator ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}), ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}), - ...tabsByWorktreeUpdate + : {}) } }) + if (worktreeId && shouldPrepareTerminalTabs) { + const prepareTerminalTabs = (): void => { + pendingActivationTerminalPrepCancels.delete(worktreeId) + set((s) => { + if (s.activeWorktreeId !== worktreeId) { + return {} + } + const tabs = s.tabsByWorktree[worktreeId] ?? [] + if (tabs.length === 0) { + return {} + } + const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) + if (!allDead && !shouldTagTerminalTabs) { + return {} + } + return { + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: tabs.map((tab) => ({ + ...tab, + ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), + // Why: slept terminal remount/spawn is click-driven wake work. + // Tag the resulting PTY updates so they do not reshuffle Recent. + pendingActivationSpawn: getActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + })) + } + } + }) + } + + const cancelExistingPrep = pendingActivationTerminalPrepCancels.get(worktreeId) + if (cancelExistingPrep) { + cancelExistingPrep() + } + if (shouldDeferActivationTerminalPrep()) { + pendingActivationTerminalPrepCancels.set( + worktreeId, + scheduleAfterInputQuiet(prepareTerminalTabs, { + delayMs: ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS, + quietMs: ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS, + idleTimeoutMs: ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS + }) + ) + } else { + prepareTerminalTabs() + } + } + // Why: activation is explicit enough to revalidate PR state immediately; // the GitHub coordinator still coalesces requests and applies rate guards. if (worktreeId) { diff --git a/tests/e2e/worktree-switch-responsiveness.spec.ts b/tests/e2e/worktree-switch-responsiveness.spec.ts new file mode 100644 index 000000000..70b62c710 --- /dev/null +++ b/tests/e2e/worktree-switch-responsiveness.spec.ts @@ -0,0 +1,153 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const WORKTREE_OPTION_PREFIX = 'worktree-list-option-' +const MAX_CLICK_TASK_DURATION_MS = 32 +const MAX_CLICK_BACK_TIMER_DRIFT_MS = 32 + +function worktreeOptionId(worktreeId: string): string { + return `${WORKTREE_OPTION_PREFIX}${encodeURIComponent(worktreeId)}` +} + +async function prepareSidebarForSwitchTest(page: Page): Promise<[string, string]> { + return page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowActiveOnly(false) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + + const repo = state.repos[0] + const worktrees = repo ? (state.worktreesByRepo[repo.id] ?? []) : [] + if (worktrees.length < 2) { + throw new Error('Worktree switch responsiveness test needs at least two worktrees') + } + + const [first, second] = worktrees + if ((state.tabsByWorktree[second.id] ?? []).length === 0) { + state.createTab(second.id, undefined, undefined, { pendingActivationSpawn: true }) + } + state.revealWorktreeInSidebar(first.id, { behavior: 'auto' }) + state.revealWorktreeInSidebar(second.id, { behavior: 'auto' }) + state.setActiveWorktree(first.id) + return [first.id, second.id] + }) +} + +test.describe('Worktree switch responsiveness', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('updates the selected workspace in the same click task when changing back', async ({ + orcaPage + }) => { + const [firstWorktreeId, secondWorktreeId] = await prepareSidebarForSwitchTest(orcaPage) + const firstRow = orcaPage.locator(`[id="${worktreeOptionId(firstWorktreeId)}"]`) + const secondRow = orcaPage.locator(`[id="${worktreeOptionId(secondWorktreeId)}"]`) + + await expect(firstRow).toBeVisible() + await expect(secondRow).toBeVisible() + await expect(firstRow).toHaveAttribute('aria-current', 'page') + + const result = await orcaPage.evaluate( + async ({ firstId, secondId, timerDelayMs }) => { + const option = (id: string): HTMLElement => { + const element = document.getElementById(`worktree-list-option-${encodeURIComponent(id)}`) + if (!element) { + throw new Error(`Missing worktree option for ${id}`) + } + return element + } + const surface = (id: string): HTMLElement => { + const element = option(id).querySelector('[data-worktree-card-surface]') + if (!element) { + throw new Error(`Missing worktree card surface for ${id}`) + } + return element + } + const visibleState = () => ({ + firstCurrent: option(firstId).getAttribute('aria-current'), + secondCurrent: option(secondId).getAttribute('aria-current'), + renderedWorktreeId: + document + .querySelector('[data-rendered-active-worktree-id]') + ?.getAttribute('data-rendered-active-worktree-id') ?? null + }) + + const before = visibleState() + const firstClickStart = performance.now() + surface(secondId).click() + const afterFirstClick = { + clickDurationMs: performance.now() - firstClickStart, + ...visibleState() + } + + const timerStart = performance.now() + const afterSecondClick = await new Promise< + ReturnType & { + clickDurationMs: number + timerDriftMs: number + } + >((resolve) => { + window.setTimeout(() => { + const firedAt = performance.now() + const secondClickStart = performance.now() + surface(firstId).click() + resolve({ + clickDurationMs: performance.now() - secondClickStart, + timerDriftMs: firedAt - timerStart - timerDelayMs, + ...visibleState() + }) + }, timerDelayMs) + }) + + await new Promise((resolve) => window.setTimeout(resolve, 700)) + const afterQuietWindow = visibleState() + + return { + before, + afterFirstClick, + afterSecondClick, + afterQuietWindow + } + }, + { firstId: firstWorktreeId, secondId: secondWorktreeId, timerDelayMs: 120 } + ) + + expect(result.before).toMatchObject({ + firstCurrent: 'page', + secondCurrent: null, + renderedWorktreeId: firstWorktreeId + }) + expect(result.afterFirstClick).toMatchObject({ + firstCurrent: null, + secondCurrent: 'page', + renderedWorktreeId: firstWorktreeId + }) + expect(result.afterFirstClick.clickDurationMs).toBeLessThanOrEqual(MAX_CLICK_TASK_DURATION_MS) + expect(result.afterSecondClick.timerDriftMs).toBeLessThanOrEqual(MAX_CLICK_BACK_TIMER_DRIFT_MS) + expect(result.afterSecondClick.clickDurationMs).toBeLessThanOrEqual(MAX_CLICK_TASK_DURATION_MS) + expect(result.afterSecondClick).toMatchObject({ + firstCurrent: 'page', + secondCurrent: null, + renderedWorktreeId: firstWorktreeId + }) + expect(result.afterQuietWindow).toMatchObject({ + firstCurrent: 'page', + secondCurrent: null, + renderedWorktreeId: firstWorktreeId + }) + }) +})