From 188b4976b06e238eda59bf916aa38d19d8f194f3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 08:36:12 -0700 Subject: [PATCH] perf(sidebar): switch workspaces on mouse press instead of click (#34594) * perf(sidebar): switch workspaces on mouse press instead of click A plain primary mouse press on a sidebar workspace row now activates it; the click that ends the same press is skipped. The ~80-120 ms a press is held before release hides the switch, so the new workspace is usually painted before the button comes up. Modified presses, touch and pen, nested controls, rows inside a multi-selection, kanban cards and keyboard-forwarded clicks keep click activation. * test(sidebar): drop wall-clock cache stub from press activation test * fix(sidebar): keep press activation off drags that wake, resume or close the board A press-activated switch now keeps focus in the new terminal: the press's native mousedown no longer moves it to the workspace list. Presses fall back to click activation when the target would be woken (sleeping workspace or agent session, slept VM, disconnected SSH), outside the terminal view, on selectable text, and while the workspace board is open, so a row dragged onto the board no longer closes it. The skip-the-next-click flag now ends with its gesture instead of surviving a drag-suppressed click. * fix(sidebar): keep press activation off hosts a focus could boot Switching workspaces focuses the row's host: a stopped WSL distro boots on focus and a recipe VM resumes on activation, both before a press is known to be a drag. A press now activates only on a proven-ready host (local, an SSH target, a WSL host reporting ready, a connected paired runtime), resolved from the row's own host so folder workspaces and host twins match; recipe-VM targets and unknown hosts keep click activation. * fix(sidebar): activate on press only for local workspaces Remote hosts can connect, boot or resume when their workspace gains focus, and the focus subscribers resolve a workspace by bare id, so a twin or an SSH folder row could still reach a remote host from a press that becomes a drag. Press activation now needs the row's own host and the id's owning host to both be local; every SSH, WSL, runtime and VM workspace keeps click activation. * fix(sidebar): keep click activation while a text field has focus or a terminal would respawn * fix(sidebar): keep click activation for workspaces without a running terminal; leave focus to the list when the press moved none * fix(sidebar): keep a multi-selected folder row's press for the group drag * fix(sidebar): drop the pre-press focus reference when the gesture ends * fix(sidebar): keep click activation while a menu or dialog is open --------- Co-authored-by: m4air --- .../WorktreeCard.activate-on-press.test.tsx | 238 ++++++++++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 2 + .../use-worktree-card-activation-actions.ts | 133 +++++++--- .../sidebar/use-worktree-card-controller.ts | 4 + .../sidebar/worktree-card-dom-events.ts | 39 +++ .../components/sidebar/worktree-card-model.ts | 2 + .../sidebar/worktree-card-surface.tsx | 4 + .../sidebar/worktree-list/rows/folder-row.tsx | 11 + .../sidebar/worktree-list/rows/item-row.tsx | 3 + .../viewport/virtual-row-context.ts | 2 + ...ktree-press-activation-eligibility.test.ts | 51 ++++ .../worktree-press-activation-eligibility.ts | 39 +++ 12 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts diff --git a/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx new file mode 100644 index 00000000000..e971cde8197 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.activate-on-press.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const fetchLinearIssue = vi.fn() +const openModal = vi.fn() +const updateWorktreeMeta = vi.fn() + +let worktreeCardProperties: WorktreeCardProperty[] = ['status'] +let isSleepingWorktree = false +let root: Root | null = null +let container: HTMLDivElement | null = null + +vi.mock('@/store', () => { + const getState = () => ({ + activeView: 'terminal', + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + repos: [{ id: 'repo-1', path: '/repo' }], + settings: { experimentalNewWorktreeCardStyle: true }, + sshConnectionStates: new Map(), + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { 'repo-1::/repo/worktrees/branch': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} + }) + } +}) + +const activateWorktreeFromSidebar = vi.fn() +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: (...args: unknown[]) => activateWorktreeFromSidebar(...args) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'active' +})) + +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false, + selectIsSleepingWorktree: () => isSleepingWorktree +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/worktrees/branch', + repoId: 'repo-1', + path: '/repo/worktrees/branch', + displayName: 'feature/branch', + branch: 'feature/branch', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +describe('WorktreeCard press activation', () => { + beforeEach(() => { + vi.clearAllMocks() + worktreeCardProperties = ['status'] + isSleepingWorktree = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + async function renderCard(props: { activateOnPress?: boolean } = {}): Promise { + const { default: WorktreeCard } = await import('./WorktreeCard') + act(() => { + root?.render( + + ) + }) + const surface = container?.querySelector('[data-worktree-card-surface]') + if (!surface) { + throw new Error('card surface not rendered') + } + return surface + } + + function press(target: HTMLElement, init: PointerEventInit = {}): void { + act(() => { + target.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse', ...init }) + ) + }) + } + + function click(target: HTMLElement, detail: number): void { + act(() => { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, detail })) + }) + } + + it('activates on a plain mouse press and skips the click that ends it', async () => { + const surface = await renderCard({ activateOnPress: true }) + const terminal = document.body.appendChild(document.createElement('textarea')) + terminal.className = 'xterm-helper-textarea' + activateWorktreeFromSidebar.mockImplementationOnce(() => terminal.focus()) + press(surface) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + // The press's native mousedown must not pull focus from the new terminal to the list. + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + act(() => { + surface.dispatchEvent(mousedown) + }) + expect(mousedown.defaultPrevented).toBe(true) + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + terminal.remove() + }) + + it('keeps click activation for modified, touch, and keyboard-forwarded presses', async () => { + const surface = await renderCard({ activateOnPress: true }) + press(surface, { metaKey: true }) + press(surface, { pointerType: 'touch' }) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + press(surface) + click(surface, 0) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(2) + }) + + it('waits for the click while a text field has focus', async () => { + const surface = await renderCard({ activateOnPress: true }) + const input = document.body.appendChild(document.createElement('input')) + input.focus() + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + input.remove() + }) + + it('waits for the click before waking a sleeping workspace', async () => { + isSleepingWorktree = true + const surface = await renderCard({ activateOnPress: true }) + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + }) + + it('does not let a press whose click was swallowed skip a later click', async () => { + const surface = await renderCard({ activateOnPress: true }) + press(surface) + act(() => { + window.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse' })) + }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(2) + }) + + it('leaves cards outside the sidebar lists on click activation', async () => { + const surface = await renderCard() + press(surface) + expect(activateWorktreeFromSidebar).not.toHaveBeenCalled() + click(surface, 1) + expect(activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index bbcc35e750e..554d6fa2a8a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -18,6 +18,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone = 'default', selectedWorktrees, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, onSelectionGesture, @@ -54,6 +55,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ revealHighlightTone, selectedWorktrees, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, onSelectionGesture, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts index 305b96aec3d..33d093f3c1d 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-activation-actions.ts @@ -3,7 +3,16 @@ import React, { useCallback, useLayoutEffect, useRef } from 'react' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' -import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' +import { isEditableTarget } from '@/lib/editable-target' +import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { + afterPointerGestureEnds, + isEventTargetInsideCurrentTarget, + isPlainPrimaryMousePress, + isSelectableTextTarget +} from './worktree-card-dom-events' +import { canActivateWorktreeOnPress } from './worktree-press-activation-eligibility' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' import type { WorktreeCardProps } from './worktree-card-model' import type { useWorktreeCardFoundation } from './use-worktree-card-foundation' import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details' @@ -19,6 +28,7 @@ export function useWorktreeCardActivationActions({ isActive, activationRowKey, onActivate, + activateOnPress, onWorktreeCardClick, onImmediateActivate, isDeleting, @@ -35,6 +45,7 @@ export function useWorktreeCardActivationActions({ | 'isActive' | 'activationRowKey' | 'onActivate' + | 'activateOnPress' | 'onWorktreeCardClick' | 'onImmediateActivate' > & @@ -47,11 +58,47 @@ export function useWorktreeCardActivationActions({ useLayoutEffect(() => { worktreeRef.current = worktree }, [worktree]) + const pressActivatedRef = useRef(false) + const focusBeforePressRef = useRef(null) + const activate = useCallback(() => { + onWorktreeCardClick?.() + // Use shared activation so sidebar navigation participates in back/forward history. + recordRendererCrashBreadcrumb('sidebar_worktree_activate', { + worktreeId: worktree.id, + repoId: worktree.repoId, + wasActive: isActive, + sshDisconnected: isSshDisconnected + }) + onImmediateActivate?.(worktree.id, activationRowKey) + void activateWorktreeFromSidebar( + worktree.id, + worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) + ) + onActivate?.() + }, [ + worktree.id, + worktree.repoId, + worktree.hostId, + repo, + isActive, + activationRowKey, + isSshDisconnected, + onActivate, + onWorktreeCardClick, + onImmediateActivate + ]) + const handleClick = useCallback( (event: React.MouseEvent) => { if (!isEventTargetInsideCurrentTarget(event.currentTarget, event.target)) { return } + const pressActivated = pressActivatedRef.current + pressActivatedRef.current = false + // Synthetic option-forwarded clicks report detail 0 and must still activate. + if (pressActivated && event.detail > 0) { + return + } const selection = window.getSelection() // Why: only suppress the click for a selection inside this card; a foreign selection must not block worktree switching. if (selection && selection.toString().length > 0) { @@ -78,38 +125,55 @@ export function useWorktreeCardActivationActions({ event.stopPropagation() return } - onWorktreeCardClick?.() - // Use shared activation so sidebar navigation participates in back/forward history. - recordRendererCrashBreadcrumb('sidebar_worktree_activate', { - worktreeId: worktree.id, - repoId: worktree.repoId, - wasActive: isActive, - sshDisconnected: isSshDisconnected - }) - onImmediateActivate?.(worktree.id, activationRowKey) - void activateWorktreeFromSidebar( - worktree.id, - worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) - ) - onActivate?.() + activate() }, - [ - affiliateListMode, - worktree.id, - worktree.repoId, - worktree.hostId, - repo, - isActive, - isDeleting, - activationRowKey, - isSshDisconnected, - onActivate, - onWorktreeCardClick, - onImmediateActivate, - onSelectionGesture - ] + [affiliateListMode, isDeleting, onSelectionGesture, activate] ) + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + pressActivatedRef.current = false + if ( + !activateOnPress || + isDeleting || + !isPlainPrimaryMousePress(event) || + !isEventTargetInsideCurrentTarget(event.currentTarget, event.target) || + isSidebarPointerDragBlocked(event.target, event.currentTarget) || + isSelectableTextTarget(event.target) || + // The press prevents mousedown's focus move, so an open edit would keep the keystrokes. + isEditableTarget(document.activeElement) || + // A closing menu or dialog restores focus after the press and would take it from the new workspace. + hasVisibleOverlay() || + !canActivateWorktreeOnPress({ + worktreeId: worktree.id, + rowHostId: worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) + }) + ) { + return + } + // A plain press replaces the selection exactly as the click would before navigating. + if (onSelectionGesture?.(event, worktreeRef.current)) { + return + } + pressActivatedRef.current = true + focusBeforePressRef.current = document.activeElement + // Drag suppression can swallow this gesture's click; the flag must not outlive it and skip a later click. + afterPointerGestureEnds(() => { + pressActivatedRef.current = false + focusBeforePressRef.current = null + }) + activate() + }, + [activateOnPress, isDeleting, onSelectionGesture, activate, worktree.id, worktree.hostId, repo] + ) + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + // When the press already focused the new workspace, the native default would move focus to the list. + if (pressActivatedRef.current && document.activeElement !== focusBeforePressRef.current) { + event.preventDefault() + } + }, []) + const handleRenameTitle = useCallback( // Store logging/refetch reverts failed optimistic renames; inline rename has no separate error surface. async (displayName: string): Promise => { @@ -162,5 +226,12 @@ export function useWorktreeCardActivationActions({ [worktree.hostId, worktree.id, worktree.isUnread, setWorkspaceUnreadByUser] ) - return { handleClick, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick } + return { + handleClick, + handlePointerDown, + handleMouseDown, + handleRenameTitle, + handleDoubleClick, + handleToggleUnreadQuick + } } diff --git a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts index c910a2b5c71..09adf9de1c7 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-controller.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-controller.ts @@ -91,6 +91,10 @@ export function useWorktreeCardController(props: ResolvedWorktreeCardProps) { isActive: props.isActive, activationRowKey: props.activationRowKey, onActivate: props.onActivate, + // Why: pressing a row inside a multi-selection starts a group drag; only the click may collapse the selection. + activateOnPress: + props.activateOnPress === true && + !(props.isMultiSelected && (props.selectedWorktrees?.length ?? 0) > 1), onWorktreeCardClick: props.onWorktreeCardClick, onImmediateActivate: props.onImmediateActivate, isDeleting: linked.isDeleting, diff --git a/src/renderer/src/components/sidebar/worktree-card-dom-events.ts b/src/renderer/src/components/sidebar/worktree-card-dom-events.ts index 33ae05c3f0f..7c51608531c 100644 --- a/src/renderer/src/components/sidebar/worktree-card-dom-events.ts +++ b/src/renderer/src/components/sidebar/worktree-card-dom-events.ts @@ -7,3 +7,42 @@ export function isEventTargetInsideCurrentTarget( } return currentTarget.contains(target) } + +// Touch and pen keep click activation so a press can still start a scroll. +export function isPlainPrimaryMousePress(event: { + pointerType: string + button: number + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean +}): boolean { + return ( + event.pointerType === 'mouse' && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) +} + +// Selectable text (e.g. port numbers) lets a press start a text selection; it keeps click activation. +export function isSelectableTextTarget(target: EventTarget | null): boolean { + return target instanceof Element && getComputedStyle(target).userSelect === 'text' +} + +// The click that ends a gesture is dispatched right after pointerup, so clear after that task; cancel never clicks. +export function afterPointerGestureEnds(callback: () => void): void { + const finish = (event: Event): void => { + window.removeEventListener('pointerup', finish, true) + window.removeEventListener('pointercancel', finish, true) + if (event.type === 'pointercancel') { + callback() + } else { + setTimeout(callback, 0) + } + } + window.addEventListener('pointerup', finish, true) + window.addEventListener('pointercancel', finish, true) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts index 43aa0fad75e..638ef68b869 100644 --- a/src/renderer/src/components/sidebar/worktree-card-model.ts +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -39,6 +39,8 @@ export type WorktreeCardProps = { onLineageToggle?: (event: React.MouseEvent) => void isLineageDropTarget?: boolean onActivate?: () => void + /** Switch on a plain primary mouse press instead of the later click; the hold before release hides the switch cost. */ + activateOnPress?: boolean onWorktreeCardClick?: () => void onImmediateActivate?: (worktreeId: string, rowKey: string | undefined) => void onSelectionGesture?: (event: React.MouseEvent, worktree: Worktree) => boolean diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index e34eb867270..ad6da0b6232 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -33,6 +33,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): isQueuedForDeletion, deleteLabel, handleClick, + handlePointerDown, + handleMouseDown, handleDoubleClick, handleDragStart, handleDragEnd, @@ -77,6 +79,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): } data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} + onPointerDown={handlePointerDown} + onMouseDown={handleMouseDown} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} draggable={!affiliateListMode && nativeDragEnabled && !isDeleting && !titleRenaming} onDragStart={!affiliateListMode && nativeDragEnabled ? handleDragStart : undefined} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 7f11ae21a18..5b95c3c95e2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -45,6 +45,8 @@ export type FolderWorkspaceRowContext = { worktree: Worktree ) => readonly Worktree[] onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + /** False while the workspace board is open, where a press may start a drag onto the board. */ + activateOnPress: boolean onRowClickCapture: (event: React.MouseEvent) => void onRowPointerDown: ( event: React.PointerEvent, @@ -125,6 +127,15 @@ export function renderFolderWorkspaceVirtualRow(args: { flushSurface nativeDragEnabled={false} onImmediateActivate={activationDisabled ? undefined : ctx.onImmediateActivate} + // A press inside a multi-selection starts a group drag; only the click may collapse it. + activateOnPress={ + ctx.activateOnPress && + !activationDisabled && + !( + ctx.selectedWorktreeIds.has(folderWorktreeIdentity) && + ctx.selectedWorktreeIds.size > 1 + ) + } activationRowKey={folderWorktreeIdentity} onSelectionGesture={(event) => ctx.onSelectionGesture(event, folderWorktree)} onContextMenuSelect={ctx.onContextMenuSelect} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 5613d20222f..d3e6425886d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -43,6 +43,8 @@ export type WorktreeItemRowContext = { getLineageToggleHandler: (groupKey: string) => LineageToggleHandler onSelectionGesture: (event: React.MouseEvent, worktree: Worktree) => boolean onWorktreeCardClick?: () => void + /** False while the workspace board is open: a press may start a drag onto the board, which activation would close. */ + activateOnPress: boolean onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -191,6 +193,7 @@ export function renderWorktreeItemRow( flushSurface activationRowKey={itemRow.rowKey} onImmediateActivate={ctx.onImmediateActivate} + activateOnPress={ctx.activateOnPress} onSelectionGesture={ctx.onSelectionGesture} onWorktreeCardClick={ctx.onWorktreeCardClick} onContextMenuSelect={ctx.onContextMenuSelect} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index c607cbd8e1a..addfc2c9457 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -128,6 +128,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual getLineageToggleHandler: args.getLineageToggleHandler, onSelectionGesture: props.onSelectionGesture, onWorktreeCardClick: props.onWorktreeCardClick, + activateOnPress: !props.workspaceBoardOpen, onContextMenuSelect: props.onContextMenuSelect, onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, onRowClickCapture: args.onRowClickCapture, @@ -152,6 +153,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, onSelectionGesture: props.onSelectionGesture, onContextMenuSelect: props.onContextMenuSelect, + activateOnPress: !props.workspaceBoardOpen, onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, onRowClickCapture: args.onRowClickCapture, onRowPointerDown: args.onRowPointerDown diff --git a/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts new file mode 100644 index 00000000000..17fc76f036d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { canActivateWorktreeOnPress } from './worktree-press-activation-eligibility' + +const WORKTREE_ID = 'repo-1::/repo' +const LOCAL_REPO = { id: 'repo-1', path: '/repo' } +const WSL_TWIN_REPO = { ...LOCAL_REPO, executionHostId: toRuntimeExecutionHostId('env-wsl') } +let state: Record = {} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => state } +})) + +vi.mock('./use-worktree-sleep-state', () => ({ + selectIsSleepingWorktree: () => false +})) + +describe('press activation hosts', () => { + beforeEach(() => { + state = { + activeView: 'terminal', + sleepingAgentSessionsByPaneKey: {}, + folderWorkspaces: [], + projectGroups: [], + repos: [LOCAL_REPO], + tabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } + }) + + it('presses only into local workspaces', () => { + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe(true) + for (const rowHostId of [toSshExecutionHostId('devbox'), toRuntimeExecutionHostId('env-wsl')]) { + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId })).toBe(false) + } + }) + + it('keeps click activation for a local row whose id also names a remote twin', () => { + state = { ...state, repos: [WSL_TWIN_REPO, LOCAL_REPO] } + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe(false) + }) + + it('keeps click activation when a terminal would spawn', () => { + for (const terminals of [{ ptyIdsByTabId: { 'tab-1': [] } }, { tabsByWorktree: {} }]) { + state = { ...state, ...terminals } + expect(canActivateWorktreeOnPress({ worktreeId: WORKTREE_ID, rowHostId: 'local' })).toBe( + false + ) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts new file mode 100644 index 00000000000..86a8639415e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-press-activation-eligibility.ts @@ -0,0 +1,39 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { workspaceHasSleepingAgentSessions } from '@/lib/worktree-agent-activation-gate' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { workspaceOwnerHostId } from '@/lib/workspace-row-owner-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { tabHasLivePty } from '../../../../shared/tab-has-live-pty' +import { selectIsSleepingWorktree } from './use-worktree-sleep-state' + +/** + * A press may only switch to an awake local workspace from the terminal view whose terminals are all + * running. Anything that would connect, boot, spawn or resume waits for the click, since a press can + * still become a drag. + */ +export function canActivateWorktreeOnPress(args: { + worktreeId: string + rowHostId: ExecutionHostId | undefined +}): boolean { + const state = useAppStore.getState() + return ( + state.activeView === 'terminal' && + args.rowHostId === LOCAL_EXECUTION_HOST_ID && + // Host-focus subscribers resolve the workspace by bare id; a remote twin must not be the one they focus. + workspaceOwnerHostId(state, args.worktreeId) === LOCAL_EXECUTION_HOST_ID && + !hasWorktreeSleepIntent(args.worktreeId) && + !workspaceHasSleepingAgentSessions(state, args.worktreeId) && + !selectIsSleepingWorktree(state, args.worktreeId) && + hasOnlyLiveTerminals(state, args.worktreeId) + ) +} + +// Activation spawns a terminal for a dead or missing tab, which can boot a WSL distro on a Windows local project. +function hasOnlyLiveTerminals( + state: Pick, + worktreeId: string +): boolean { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + return tabs.length > 0 && tabs.every((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) +}