diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1538a7212ff..e65ab274a4a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1584,12 +1584,10 @@ 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. */} @@ -1694,7 +1692,7 @@ function App(): React.JSX.Element { @@ -1709,7 +1707,7 @@ function App(): React.JSX.Element { @@ -1758,7 +1756,7 @@ function App(): React.JSX.Element { @@ -1769,7 +1767,7 @@ function App(): React.JSX.Element { @@ -1793,15 +1791,16 @@ function App(): React.JSX.Element { - {/* 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. */} + {/* 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. */} {showRightSidebarControls ? ( diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 7caa44fa34a..990ecc3553d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -830,17 +830,6 @@ 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 4ed8f3e23ea..e76602fe12a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -178,11 +178,8 @@ 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) @@ -230,9 +227,7 @@ 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 = renderedActiveWorktreeId - ? tabBarOrderByWorktree[renderedActiveWorktreeId] - : undefined + const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : 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/ @@ -244,8 +239,8 @@ function Terminal(): React.JSX.Element | null { ) const tabs = useMemo( - () => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []), - [renderedActiveWorktreeId, tabsByWorktree] + () => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []), + [activeWorktreeId, tabsByWorktree] ) // Why: the TabBar is rendered into the titlebar via a portal so tabs share @@ -267,22 +262,22 @@ function Terminal(): React.JSX.Element | null { }, [activeWorktreeId, ensureWorktreeRootGroup]) // Filter editor files to only show those belonging to the active worktree - const worktreeFiles = renderedActiveWorktreeId - ? openFiles.filter((f) => f.worktreeId === renderedActiveWorktreeId) + const worktreeFiles = activeWorktreeId + ? openFiles.filter((f) => f.worktreeId === activeWorktreeId) : [] - const worktreeBrowserTabs = renderedActiveWorktreeId - ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []) + const worktreeBrowserTabs = activeWorktreeId + ? (browserTabsByWorktree[activeWorktreeId] ?? []) : [] const getEffectiveLayoutForWorktree = useCallback( (worktreeId: string) => getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree), [activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree] ) - const effectiveActiveLayout = renderedActiveWorktreeId - ? getEffectiveLayoutForWorktree(renderedActiveWorktreeId) + const effectiveActiveLayout = activeWorktreeId + ? getEffectiveLayoutForWorktree(activeWorktreeId) : undefined - const activeWorktreeBrowserTabIdsKey = renderedActiveWorktreeId - ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []).map((tab) => tab.id).join(',') + const activeWorktreeBrowserTabIdsKey = activeWorktreeId + ? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',') : '' // Save confirmation dialog state @@ -636,6 +631,8 @@ 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(() => { @@ -692,8 +689,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 (renderedActiveWorktreeId && workspaceSessionReady) { - mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) + if (activeWorktreeId && workspaceSessionReady) { + mountedWorktreeIdsRef.current.add(activeWorktreeId) } // Prune IDs of worktrees that no longer exist (deleted/removed) const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id)) @@ -1550,12 +1547,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 = renderedActiveWorktreeId - ? (useAppStore.getState().browserTabsByWorktree[renderedActiveWorktreeId] ?? []) + const activeWorktreeBrowserTabs = activeWorktreeId + ? (useAppStore.getState().browserTabsByWorktree[activeWorktreeId] ?? []) : [] if ( activeTabType === 'browser' && - renderedActiveWorktreeId && + activeWorktreeId && (!activeBrowserTabId || !activeWorktreeBrowserTabs.some((tab) => tab.id === activeBrowserTabId)) ) { @@ -1568,7 +1565,7 @@ function Terminal(): React.JSX.Element | null { } }, [ activeTabType, - renderedActiveWorktreeId, + activeWorktreeId, activeBrowserTabId, activeWorktreeBrowserTabIdsKey, setActiveBrowserTab, @@ -1577,22 +1574,21 @@ 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. */} - {renderedActiveWorktreeId && + {activeWorktreeId && !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 === renderedActiveWorktreeId + const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) return ( @@ -1778,7 +1772,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 === renderedActiveWorktreeId + activeView === 'terminal' && worktree.id === activeWorktreeId if (browserTabs.length === 0) { return null } @@ -1807,7 +1801,7 @@ function Terminal(): React.JSX.Element | null { })}
- {renderedActiveWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( + {activeWorktreeId && 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 0bc3965c11c..dec1c71c734 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 { useRepoById } from '@/store/selectors' +import { useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' import type { ActivityBarPosition } from '@/store/slices/editor' @@ -54,18 +54,14 @@ 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((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null)) + const checksStatus = useAppStore(getActiveChecksStatus) const activityBarPosition = useAppStore((s) => s.activityBarPosition) const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) const [topActivityStripWidth, setTopActivityStripWidth] = useState(null) @@ -138,7 +134,7 @@ function RightSidebarInner(): React.JSX.Element { }) const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth) - const panelContent = rightSidebarOpen ? ( + const panelContent = (
{/* Why: sidebar panels no longer use key={activeWorktreeId} because the full unmount/remount cycle on every worktree switch triggered @@ -164,7 +160,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 55ce2be1d57..aa76035d6cc 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,7 +6,6 @@ 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' })) @@ -25,7 +24,7 @@ vi.mock('@/hooks/useShortcutLabel', () => ({ vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ - rightSidebarOpen: mockAppState.rightSidebarOpen, + rightSidebarOpen: true, rightSidebarWidth: 350, setRightSidebarWidth: vi.fn(), rightSidebarTab: 'explorer', @@ -131,7 +130,6 @@ function expectNoDrag(tag: string): void { describe('rendered right sidebar titlebar drag regions', () => { beforeEach(() => { - mockAppState.rightSidebarOpen = true mockAppState.activityBarPosition = 'top' }) @@ -190,16 +188,4 @@ 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 b7dce278b97..ec4b3a9078e 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 { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors' +import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } 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 activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const activeWorktree = useWorktreeById(activeWorktreeId) + const activeWorktree = useActiveWorktree() const allWorktrees = useAllWorktrees() + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) 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 56ad15f3ccd..2c8ab90fe4d 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 { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { @@ -71,12 +71,8 @@ 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, - worktree: Worktree - ) => readonly Worktree[] + onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] onCardDragStart?: ( event: React.DragEvent, worktreeId: string, @@ -107,7 +103,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone = 'default', selectedWorktrees, onActivate, - onImmediateActivate, onSelectionGesture, onContextMenuSelect, onCardDragStart, @@ -405,14 +400,13 @@ 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. - onImmediateActivate?.(worktree.id) - activateWorktreeFromSidebar(worktree.id) + activateAndRevealWorktree(worktree.id) if (isSshDisconnected) { setShowDisconnectedDialog(true) } onActivate?.() }, - [worktree.id, isSshDisconnected, onActivate, onImmediateActivate, onSelectionGesture] + [worktree.id, isSshDisconnected, onActivate, onSelectionGesture] ) const handleRenameTitle = useCallback( @@ -488,11 +482,6 @@ 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. @@ -691,8 +680,6 @@ 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} @@ -987,7 +974,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 1a9499fd0ed..aa916ce5b7a 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,6 +1,5 @@ /* 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 @@ -20,7 +19,6 @@ import { Workflow } from 'lucide-react' import { useAppStore } from '@/store' -import type { AppState } from '@/store/types' import { getAllWorktreesFromState, useAllWorktrees, @@ -66,6 +64,7 @@ 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, @@ -104,17 +103,14 @@ import { } from './visible-worktrees' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs, - getWorktreeSectionTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs } 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' @@ -209,9 +205,6 @@ 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 @@ -284,35 +277,6 @@ 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, @@ -369,7 +333,6 @@ type VirtualizedWorktreeViewportProps = { selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean - onImmediateWorktreeActivate: (worktreeId: string) => void onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -711,7 +674,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktreeIds, selectedWorktrees, onSelectionGesture, - onImmediateWorktreeActivate, onContextMenuSelect, repoMap, worktreeMap, @@ -2920,9 +2882,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp nativeDragEnabled={false} contentIndent={nested ? 0 : paddingLeft} flushSurface={!nested} - onImmediateActivate={onImmediateWorktreeActivate} onSelectionGesture={onSelectionGesture} - onContextMenuSelect={onContextMenuSelect} + onContextMenuSelect={(event) => onContextMenuSelect(event, itemRow.worktree)} onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} @@ -2954,8 +2915,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (selectionOnly) { return } - onImmediateWorktreeActivate(child.worktree.id) - activateWorktreeFromSidebar(child.worktree.id) + activateAndRevealWorktree(child.worktree.id) if (child.repo?.connectionId) { const sshStatus = useAppStore.getState().sshConnectionStates.get(child.repo.connectionId) @@ -2981,8 +2941,6 @@ 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 && [ @@ -3230,7 +3188,6 @@ 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) @@ -3251,20 +3208,10 @@ const WorktreeList = React.memo(function WorktreeList({ const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - // 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 agentTargetStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const agentTargetStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const agentTargetTabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const agentTargetTerminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const agentSendTargetWorktreeId = useMemo(() => { void agentTargetStatusEpoch if (!agentSendPopoverTargetMode) { @@ -3309,14 +3256,11 @@ 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) => - getWorktreeSectionTerminalActivityTabs(s.tabsByWorktree) - ) - const sectionActivityBrowserTabsByWorktree = useAppStore((s) => - getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) - ) + const sectionActivityTabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const sectionActivityBrowserTabsByWorktree = useAppStore((s) => 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 @@ -3602,13 +3546,6 @@ 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) @@ -3715,7 +3652,7 @@ const WorktreeList = React.memo(function WorktreeList({ browserTabsByWorktree: sectionActivityBrowserTabsByWorktree, ptyIdsByTabId: sectionActivityPtyIdsByTabId, runtimePaneTitlesByTabId: sectionActivityRuntimePaneTitlesByTabId, - terminalLayoutRootsByTabId: sectionActivityTerminalLayoutRootsByTabId, + terminalLayoutsByTabId: sectionActivityTerminalLayoutsByTabId, agentStatusEpoch: sectionActivityAgentStatusEpoch, // Why: agentStatusByPaneKey can tick for same-state tool details. The // section counts only need structural status transitions, tracked by @@ -3730,8 +3667,8 @@ const WorktreeList = React.memo(function WorktreeList({ sectionActivityMigrationUnsupportedByPtyId, sectionActivityPtyIdsByTabId, sectionActivityRetainedAgentsByPaneKey, - sectionActivityTerminalLayoutRootsByTabId, sectionActivityRuntimePaneTitlesByTabId, + sectionActivityTerminalLayoutsByTabId, sectionActivityTabsByWorktree ]) const sectionActivityByGroupKey = useMemo( @@ -3798,9 +3735,17 @@ const WorktreeList = React.memo(function WorktreeList({ importedWorktreesByRepo ] ) - // 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: 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: derive the rendered item order from the post-buildRows() row list, // not the flat `worktrees` array, because grouping (groupBy: 'repo' or @@ -3894,17 +3839,10 @@ 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 : currentSidebarWorktreeId + activeView === 'tasks' || activeView === 'activity' ? null : activeWorktreeId // 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. @@ -4307,7 +4245,7 @@ const WorktreeList = React.memo(function WorktreeList({ key={viewportResetKey} rows={rows} activeWorktreeId={selectedSidebarWorktreeId} - currentWorktreeId={currentSidebarWorktreeId} + currentWorktreeId={activeWorktreeId} groupBy={groupBy} projectGroupOrdering={projectGroupOrdering} toggleGroup={toggleGroup} @@ -4332,7 +4270,6 @@ 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 b1ccde05042..f98099d6888 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,13 +40,6 @@ 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 @@ -55,6 +48,6 @@ export function resolveRuntimePaneTitleLeafIdFromRoot( if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) { return null } - const leafIds = collectLeafIdsInReplayCreationOrder(root) + const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.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 3abec427589..ea7b767027f 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 terminalLayoutRootsByTabId = useAppStore( - useShallow((s) => selectTerminalLayoutRootsForWorktree(s, worktreeId)) + const terminalLayoutsByTabId = useAppStore( + useShallow((s) => selectTerminalLayoutsForWorktree(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, - terminalLayoutRootsByTabId, + terminalLayoutsByTabId, hasPermission, hasLiveWorking, hasLiveDone, @@ -48,7 +48,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ptyIdsForWorktree, runtimePaneTitlesForWorktree, freshHookLeafIdsByTabId, - terminalLayoutRootsByTabId, + terminalLayoutsByTabId, 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 6012330e53a..c96aa38d50d 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,8 +2,7 @@ import { describe, expect, it } from 'vitest' import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs, - getWorktreeSectionTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs } from './visible-worktree-activity-inputs' function terminalTab(id: string, title: string): TerminalTab { @@ -95,40 +94,6 @@ 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 ff81a9e201c..d3ca4416568 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,31 +1,26 @@ import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' -export type TerminalActivityTab = Pick -export type BrowserActivityTab = Pick -export type WorktreeSectionTerminalActivityTab = Pick +type TerminalActivityTab = Pick +type BrowserActivityTab = Pick -function haveSameProjection( - previous: readonly U[] | undefined, - next: readonly T[], - isSame: (previousTab: U, nextTab: T) => boolean +function haveSameIds( + previous: readonly T[] | undefined, + next: readonly { id: string }[] ): boolean { if (!previous || previous.length !== next.length) { return false } for (let index = 0; index < next.length; index++) { - const previousTab = previous[index] - if (!previousTab || !isSame(previousTab, next[index])) { + if (previous[index]?.id !== next[index]?.id) { return false } } return true } -function projectTabs( +function projectIdTabs( tabsByWorktree: Record, - previousProjection: Record | null, - projectTab: (tab: T) => U, - isSame: (previousTab: U, nextTab: T) => boolean + previousProjection: Record | null ): { projection: Record; unchanged: boolean } { const nextProjection: Record = {} let unchanged = @@ -34,29 +29,17 @@ function projectTabs( for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { const previousTabs = previousProjection?.[worktreeId] - if (haveSameProjection(previousTabs, tabs, isSame)) { + if (haveSameIds(previousTabs, tabs)) { nextProjection[worktreeId] = previousTabs as U[] continue } unchanged = false - nextProjection[worktreeId] = tabs.map(projectTab) + nextProjection[worktreeId] = tabs.map((tab) => ({ id: tab.id }) as U) } 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 @@ -75,30 +58,6 @@ 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 c48cdb9acad..68c7e6f9c15 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -25,20 +25,17 @@ 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: AgentActivityTabsByWorktree + tabsByWorktree: AppState['tabsByWorktree'] 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 0030e7d931e..ce6aa8f79b9 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,19 +1,12 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../../../shared/types' +import type { 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 { @@ -28,15 +21,6 @@ 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' @@ -107,42 +91,4 @@ 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 e06894441cf..d0552e89f18 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,16 +1,12 @@ 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 & { - tabsByWorktree: Record -} - -type WorktreeCardLayoutRootInputState = Pick & { - tabsByWorktree: Record -} +type WorktreeCardStatusInputState = Pick< + AppState, + 'tabsByWorktree' | 'runtimePaneTitlesByTabId' | 'ptyIdsByTabId' +> export function selectRuntimePaneTitlesForWorktree( state: WorktreeCardStatusInputState, @@ -39,27 +35,3 @@ 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 0baedd556c7..0923c814783 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: {}, - terminalLayoutRootsByTabId: {}, + terminalLayoutsByTabId: {}, 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 f5e7f65b966..1f4c2cf69d9 100644 --- a/src/renderer/src/components/sidebar/worktree-section-activity.ts +++ b/src/renderer/src/components/sidebar/worktree-section-activity.ts @@ -3,8 +3,6 @@ import { resolveWorktreeStatus } from '@/lib/worktree-status' import type { ProjectGroup, Repo, - TerminalPaneLayoutNode, - TerminalTab, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' @@ -17,22 +15,21 @@ 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 @@ -106,7 +103,7 @@ function getSectionWorktreeStatus( ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), freshHookLeafIdsByTabId: agentSummary.freshHookLeafIdsByTabId, - terminalLayoutRootsByTabId: state.terminalLayoutRootsByTabId, + terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(state, worktreeId), 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 deleted file mode 100644 index 7e251ee6813..00000000000 --- a/src/renderer/src/lib/input-quiet-scheduler.ts +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 0710c064fc7..00000000000 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index fec3e32805f..00000000000 --- a/src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 581329cf78e..95a5bb150fd 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,18 +1,13 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/components/sidebar/runtime-pane-title-leaf-id' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../../shared/types' +import { resolveRuntimePaneTitleLeafId } from '@/components/sidebar/runtime-pane-title-leaf-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' type WorktreeStatusHeuristicOptions = { freshHookLeafIdsByTabId?: Record> terminalLayoutsByTabId?: Record - terminalLayoutRootsByTabId?: Record } const STATUS_LABELS: Record = { @@ -24,8 +19,8 @@ const STATUS_LABELS: Record = { } export function getWorktreeStatus( - tabs: readonly Pick[], - browserTabs: readonly { id: string }[], + tabs: Pick[], + browserTabs: { id: string }[], ptyIdsByTabId: Record, runtimePaneTitlesByTabId: Record> = {}, options: WorktreeStatusHeuristicOptions = {} @@ -74,11 +69,10 @@ function tabHasStatus( const hookLeafIds = options.freshHookLeafIdsByTabId?.[tab.id] const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { - const tabLayoutRoot = - options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root + const tabLayout = options.terminalLayoutsByTabId?.[tab.id] const paneTitleEntries = Object.entries(paneTitles) for (const [runtimePaneId, title] of paneTitleEntries) { - const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) + const leafId = resolveRuntimePaneTitleLeafId(tabLayout, 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. @@ -129,13 +123,12 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { * - `hasRetainedDone`: any retained-agent snapshot scoped to this worktreeId. */ export function resolveWorktreeStatus(args: { - tabs: readonly Pick[] - browserTabs: readonly { id: string }[] + tabs: Pick[] + browserTabs: { id: string }[] ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record> freshHookLeafIdsByTabId?: Record> terminalLayoutsByTabId?: Record - terminalLayoutRootsByTabId?: Record hasPermission: boolean hasLiveWorking: boolean hasLiveDone: boolean @@ -148,8 +141,7 @@ export function resolveWorktreeStatus(args: { args.runtimePaneTitlesByTabId ?? {}, { freshHookLeafIdsByTabId: args.freshHookLeafIdsByTabId, - terminalLayoutsByTabId: args.terminalLayoutsByTabId, - terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId + terminalLayoutsByTabId: args.terminalLayoutsByTabId } ) if (args.hasPermission) { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 39005b28615..07e773e2b01 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -35,16 +35,11 @@ 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) { @@ -65,10 +60,6 @@ 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 @@ -1966,10 +1957,6 @@ export const createWorktreeSlice: StateCreator }, setActiveWorktree: (worktreeId) => { - if (worktreeId && shouldDeferActivationTerminalPrep()) { - markInputQuietSchedulerInput() - } - if (get().activeWorktreeId !== worktreeId) { moveFocusToRendererBeforeFocusedWebviewHidden() } @@ -1977,8 +1964,6 @@ export const createWorktreeSlice: StateCreator ? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId : null let shouldClearUnread = false - let shouldPrepareTerminalTabs = false - let shouldTagTerminalTabs = false set((s) => { if (!worktreeId) { return { @@ -2104,9 +2089,12 @@ export const createWorktreeSlice: StateCreator // PTY lifecycle and explicit edits still flow through bumpWorktreeActivity. const metaUpdates: Partial = shouldClearUnread ? { isUnread: false } : {} - // 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: 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 pendingActivationSpawn + first-activation check: the first time a // worktree is activated in this session, its TerminalPane mounts and @@ -2131,13 +2119,32 @@ 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 @@ -2156,60 +2163,11 @@ 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 deleted file mode 100644 index 70b62c71052..00000000000 --- a/tests/e2e/worktree-switch-responsiveness.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 - }) - }) -})