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