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