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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 08:36:12 -07:00
committed by GitHub
co-authored by m4air
parent df98fceae1
commit 188b4976b0
12 changed files with 497 additions and 31 deletions
@@ -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> = {}): 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<HTMLElement> {
const { default: WorktreeCard } = await import('./WorktreeCard')
act(() => {
root?.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
activateOnPress={props.activateOnPress}
/>
)
})
const surface = container?.querySelector<HTMLElement>('[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)
})
})
@@ -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,
@@ -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<Element | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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<void> => {
@@ -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
}
}
@@ -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,
@@ -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)
}
@@ -39,6 +39,8 @@ export type WorktreeCardProps = {
onLineageToggle?: (event: React.MouseEvent<HTMLButtonElement>) => 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<HTMLElement>, worktree: Worktree) => boolean
@@ -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}
@@ -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<HTMLDivElement>) => void
onRowPointerDown: (
event: React.PointerEvent<HTMLDivElement>,
@@ -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}
@@ -43,6 +43,8 @@ export type WorktreeItemRowContext = {
getLineageToggleHandler: (groupKey: string) => LineageToggleHandler
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, 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<HTMLElement>,
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}
@@ -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
@@ -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<string, unknown> = {}
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
)
}
})
})
@@ -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<AppState, 'tabsByWorktree' | 'ptyIdsByTabId'>,
worktreeId: string
): boolean {
const tabs = state.tabsByWorktree[worktreeId] ?? []
return tabs.length > 0 && tabs.every((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
}