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