fix: keep workspace switching responsive (#4362)

This commit is contained in:
Neil 2026-05-31 22:55:15 -07:00 committed by GitHub
parent 6f59ea18c5
commit 38c8a0a5ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 856 additions and 149 deletions

View File

@ -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. */}
<RetainedAgentsSyncGate />
{/* Why: workspace activation is a hot path; including activeWorktreeId
in reset keys remounts whole surfaces during wake. */}
<RecoverableRenderErrorBoundary
boundaryId="app.workspace-shell"
surface="workspace-shell"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
resetKey={activeView}
title="The workspace shell hit an error."
description="The app is still running. Retry the shell or use the menu to report the crash details."
>
@ -1692,7 +1694,7 @@ function App(): React.JSX.Element {
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
resetKey={activeView}
title="The workspace list hit an error."
description="The active workspace remains open. Retry the list or switch views."
>
@ -1707,7 +1709,7 @@ function App(): React.JSX.Element {
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
resetKey={activeView}
title="The workspace list hit an error."
description="The active page remains open. Retry the list or switch views."
>
@ -1756,7 +1758,7 @@ function App(): React.JSX.Element {
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey={activeWorktreeId ?? 'none'}
resetKey="terminal"
title="The workspace workbench hit an error."
description="Terminal, browser, or editor rendering failed in this workspace. Retry to remount it."
>
@ -1767,7 +1769,7 @@ function App(): React.JSX.Element {
<RecoverableRenderErrorBoundary
boundaryId={`page.${activeView}`}
surface="page"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
resetKey={activeView}
title="This page hit an error."
description="Retry the page or navigate to another Orca surface."
>
@ -1791,16 +1793,15 @@ function App(): React.JSX.Element {
</div>
</div>
</div>
{/* 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 ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={`${activeWorktreeId ?? 'none'}:${rightSidebarTab}`}
resetKey={rightSidebarTab}
title="The right sidebar hit an error."
description="Retry the sidebar or switch tabs to reload this surface."
>

View File

@ -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);
}

View File

@ -178,8 +178,11 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
}
function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
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<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
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 (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${activeWorktreeId ? '' : ' hidden'}`}
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${renderedActiveWorktreeId ? '' : ' hidden'}`}
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
>
<EditorAutosaveController />
{/* 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(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
worktreeId={activeWorktreeId}
worktreeId={renderedActiveWorktreeId}
onActivate={handleActivateTab}
onClose={handleCloseTab}
onCloseOthers={handleCloseOthers}
@ -1643,7 +1647,8 @@ function Terminal(): React.JSX.Element | null {
}
// 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 (
@ -1701,7 +1706,8 @@ function Terminal(): React.JSX.Element | null {
.map((worktree) => {
// 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 {
})}
</div>
{activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
{renderedActiveWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">

View File

@ -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<number | null>(null)
@ -134,7 +138,7 @@ function RightSidebarInner(): React.JSX.Element {
})
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
const panelContent = (
const panelContent = rightSidebarOpen ? (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
{/* 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 {
)}
</div>
</div>
)
) : null
const topActivityLayout = useMemo(
() => getTopActivityBarLayout(visibleItems, topActivityStripWidth, effectiveTab),

View File

@ -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<string, unknown>) => 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(<RightSidebar />)
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')
})
})

View File

@ -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)

View File

@ -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<HTMLButtonElement>) => void
onActivate?: () => void
onImmediateActivate?: (worktreeId: string) => void
onSelectionGesture?: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
onContextMenuSelect?: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onCardDragStart?: (
event: React.DragEvent<HTMLDivElement>,
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<HTMLElement>) => onContextMenuSelect?.(event, worktree) ?? [worktree],
[onContextMenuSelect, worktree]
)
const stopQuickActionPointerPropagation = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
// 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({
<WorktreeContextMenu
worktree={worktree}
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={onContextMenuSelect}
onContextMenuSelect={handleContextMenuSelect}
>
{cardBody}
</WorktreeContextMenu>

View File

@ -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<HTMLElement>('[data-worktree-sidebar]') ??
document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const previousOption = sidebar?.querySelector<HTMLElement>('[role="option"][aria-current="page"]')
if (previousOption && previousOption !== nextOption) {
previousOption.removeAttribute('aria-current')
}
nextOption.setAttribute('aria-current', 'page')
sidebar
?.querySelectorAll<HTMLElement>(
'[data-worktree-card-surface][data-worktree-card-active="true"]'
)
.forEach((surface) => {
if (!nextOption.contains(surface)) {
surface.removeAttribute('data-worktree-card-active')
}
})
nextOption
.querySelector<HTMLElement>('[data-worktree-card-surface]')
?.setAttribute('data-worktree-card-active', 'true')
}
function revealMountedWorktreeElement(
container: HTMLElement,
worktreeId: string,
@ -333,6 +369,7 @@ type VirtualizedWorktreeViewportProps = {
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onImmediateWorktreeActivate: (worktreeId: string) => void
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
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+19 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}

View File

@ -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
}

View File

@ -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,

View File

@ -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')]

View File

@ -1,26 +1,31 @@
import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types'
type TerminalActivityTab = Pick<TerminalTab, 'id'>
type BrowserActivityTab = Pick<BrowserWorkspace, 'id'>
export type TerminalActivityTab = Pick<TerminalTab, 'id'>
export type BrowserActivityTab = Pick<BrowserWorkspace, 'id'>
export type WorktreeSectionTerminalActivityTab = Pick<TerminalTab, 'id' | 'title'>
function haveSameIds<T extends { id: string }>(
previous: readonly T[] | undefined,
next: readonly { id: string }[]
function haveSameProjection<T, U>(
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<T extends { id: string }, U extends { id: string }>(
function projectTabs<T, U>(
tabsByWorktree: Record<string, readonly T[]>,
previousProjection: Record<string, U[]> | null
previousProjection: Record<string, U[]> | null,
projectTab: (tab: T) => U,
isSame: (previousTab: U, nextTab: T) => boolean
): { projection: Record<string, U[]>; unchanged: boolean } {
const nextProjection: Record<string, U[]> = {}
let unchanged =
@ -29,17 +34,29 @@ function projectIdTabs<T extends { id: string }, U extends { id: string }>(
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<T extends { id: string }, U extends { id: string }>(
tabsByWorktree: Record<string, readonly T[]>,
previousProjection: Record<string, U[]> | null
): { projection: Record<string, U[]>; unchanged: boolean } {
return projectTabs(
tabsByWorktree,
previousProjection,
(tab) => ({ id: tab.id }) as U,
(previousTab, nextTab) => previousTab.id === nextTab.id
)
}
let cachedTerminalSource: Record<string, TerminalTab[]> | null = null
let cachedTerminalProjection: Record<string, TerminalActivityTab[]> | null = null
@ -58,6 +75,30 @@ export function getVisibleWorktreeTerminalActivityTabs(
return projection
}
let cachedSectionTerminalSource: Record<string, TerminalTab[]> | null = null
let cachedSectionTerminalProjection: Record<string, WorktreeSectionTerminalActivityTab[]> | null =
null
export function getWorktreeSectionTerminalActivityTabs(
tabsByWorktree: Record<string, TerminalTab[]>
): Record<string, WorktreeSectionTerminalActivityTab[]> {
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<string, BrowserWorkspace[]> | null = null
let cachedBrowserProjection: Record<string, BrowserActivityTab[]> | null = null

View File

@ -25,17 +25,20 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
freshHookLeafIdsByTabId: EMPTY_HOOK_LEAF_IDS_BY_TAB_ID
}
type AgentActivityTabsByWorktree = Record<string, readonly { id: string }[]>
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']

View File

@ -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<typeof selectRuntimePaneTitlesForWorktree>[0]
type LayoutRootSelectorState = Parameters<typeof selectTerminalLayoutRootsForWorktree>[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)
})
})

View File

@ -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<AppState, 'runtimePaneTitlesByTabId' | 'ptyIdsByTabId'> & {
tabsByWorktree: Record<string, readonly { id: string }[]>
}
type WorktreeCardLayoutRootInputState = Pick<AppState, 'terminalLayoutsByTabId'> & {
tabsByWorktree: Record<string, readonly { id: string }[]>
}
export function selectRuntimePaneTitlesForWorktree(
state: WorktreeCardStatusInputState,
@ -35,3 +39,27 @@ export function selectLivePtyIdsForWorktree(
}
return out
}
export function selectTerminalLayoutRootsForWorktree(
state: WorktreeCardLayoutRootInputState,
worktreeId: string
): Record<string, TerminalPaneLayoutNode | null | undefined> {
const out: Record<string, TerminalPaneLayoutNode | null | undefined> = {}
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<string, TerminalPaneLayoutNode | null | undefined> {
const out: Record<string, TerminalPaneLayoutNode | null | undefined> = {}
for (const worktreeId of worktreeIds) {
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root
}
}
return out
}

View File

@ -83,7 +83,7 @@ function makeState(
browserTabsByWorktree: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {},
terminalLayoutRootsByTabId: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},

View File

@ -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<string, readonly Pick<TerminalTab, 'id' | 'title'>[]>
browserTabsByWorktree: Record<string, readonly BrowserActivityTab[]>
terminalLayoutRootsByTabId: Record<string, TerminalPaneLayoutNode | null | undefined>
}
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,

View File

@ -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)
}
}
}

View File

@ -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
})
}
}

View File

@ -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')
})
})

View File

@ -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<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
}
const STATUS_LABELS: Record<WorktreeStatus, string> = {
@ -19,8 +24,8 @@ const STATUS_LABELS: Record<WorktreeStatus, string> = {
}
export function getWorktreeStatus(
tabs: Pick<TerminalTab, 'id' | 'title'>[],
browserTabs: { id: string }[],
tabs: readonly Pick<TerminalTab, 'id' | 'title'>[],
browserTabs: readonly { id: string }[],
ptyIdsByTabId: Record<string, string[]>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {},
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<TerminalTab, 'id' | 'title'>[]
browserTabs: { id: string }[]
tabs: readonly Pick<TerminalTab, 'id' | 'title'>[]
browserTabs: readonly { id: string }[]
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
freshHookLeafIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
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) {

View File

@ -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<string, () => 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<AppState, [], [], WorktreeSlice>
},
setActiveWorktree: (worktreeId) => {
if (worktreeId && shouldDeferActivationTerminalPrep()) {
markInputQuietSchedulerInput()
}
if (get().activeWorktreeId !== worktreeId) {
moveFocusToRendererBeforeFocusedWebviewHidden()
}
@ -1964,6 +1977,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
? 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<AppState, [], [], WorktreeSlice>
// PTY lifecycle and explicit edits still flow through bumpWorktreeActivity.
const metaUpdates: Partial<WorktreeMeta> = 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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
...(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) {

View File

@ -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<HTMLElement>('[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<typeof visibleState> & {
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
})
})
})