feat(terminal): add manual parking developer action

This commit is contained in:
Neil
2026-07-27 16:57:42 -07:00
parent 79ec57d045
commit 6009280c8b
15 changed files with 398 additions and 9 deletions
+19 -9
View File
@@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import {
combineTerminalWorktreeParkIds,
useManualTerminalWorktreeParking
} from './terminal-pane/use-manual-terminal-worktree-parking'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null {
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
() => new Set()
)
const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({
activeView,
renderedActiveWorktreeId
})
const effectiveParkedTerminalWorktreeIds = useMemo(
() =>
combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds),
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds]
)
// Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs.
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
// Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab.
@@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null {
const coldWorktreePresentationTargets = useMemo(
() =>
activeView === 'terminal' && activeTabType === 'terminal'
? parkedTerminalWorktreeIds
? effectiveParkedTerminalWorktreeIds
: new Set<string>(),
[activeTabType, activeView, parkedTerminalWorktreeIds]
[activeTabType, activeView, effectiveParkedTerminalWorktreeIds]
)
const {
presentationByScope: worktreePresentationByScope,
@@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const parked =
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
if (parked) {
for (const tab of tabs) {
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
@@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null {
backgroundMountRevision,
getEffectiveLayoutForWorktree,
groupsByWorktree,
parkedTerminalWorktreeIds,
effectiveParkedTerminalWorktreeIds,
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
@@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${workspace.id}`}
@@ -2159,9 +2170,8 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<div
key={workspace.id}
@@ -55,6 +55,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover'
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
import { translate } from '@/i18n/i18n'
@@ -924,6 +925,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
</>
) : null}
{!isMultiContext ? (
<>
<WorktreeDeveloperMenu worktreeId={worktree.id} disabled={isDeleting} />
<DropdownMenuSeparator />
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
type MenuItemProps = {
children?: ReactNode
onSelect?: () => void
}
const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] }))
const requestManualPark = vi.hoisted(() => vi.fn())
vi.mock('@/components/ui/dropdown-menu', async () => {
const React_ = await import('react')
const passthrough = ({ children }: { children?: ReactNode }) =>
React_.createElement(React_.Fragment, null, children)
return {
DropdownMenuSub: passthrough,
DropdownMenuSubContent: passthrough,
DropdownMenuSubTrigger: passthrough,
DropdownMenuItem: (props: MenuItemProps) => {
menuItems.values.push(props)
return React_.createElement(React_.Fragment, null, props.children)
}
}
})
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/lib/manual-terminal-worktree-parking', () => ({
requestManualTerminalWorktreePark: requestManualPark
}))
describe('WorktreeDeveloperMenu', () => {
beforeEach(() => {
menuItems.values = []
requestManualPark.mockReset()
})
it('shows the developer submenu with the parking action', () => {
const markup = renderToStaticMarkup(
<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />
)
expect(markup).toContain('Developer')
expect(markup).toContain('Park terminal')
expect(markup).toContain('lucide-code-xml')
expect(markup).toContain('lucide-square-parking')
})
it('requests parking for the context worktree', () => {
renderToStaticMarkup(<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />)
menuItems.values[0]?.onSelect?.()
expect(requestManualPark).toHaveBeenCalledWith('worktree-1')
})
})
@@ -0,0 +1,32 @@
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { CodeXml, SquareParking } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking'
export function WorktreeDeveloperMenu({
worktreeId,
disabled
}: {
worktreeId: string
disabled: boolean
}): React.JSX.Element {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={disabled}>
<CodeXml className="size-3.5" />
{translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
<DropdownMenuItem onSelect={() => requestManualTerminalWorktreePark(worktreeId)}>
<SquareParking className="size-3.5" />
{translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
@@ -7,6 +7,7 @@ import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
canManuallyParkTerminalWorktreeRenderers,
canParkTerminalTabRenderer,
canParkTerminalWorktreeRenderers,
getTerminalTabColdParkRecheckDelayMs,
@@ -181,6 +182,36 @@ describe('canParkTerminalTabRenderer', () => {
})
})
describe('canManuallyParkTerminalWorktreeRenderers', () => {
const base = {
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
parkingEnabled: true
}
it('bypasses visibility timing for snapshot-backed local terminals', () => {
expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('preserves safety gates for empty, pending, remote, and disabled terminals', () => {
expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false)
expect(
canManuallyParkTerminalWorktreeRenderers({
...base,
pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] }
})
).toBe(false)
expect(
canManuallyParkTerminalWorktreeRenderers({
...base,
terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }]
})
).toBe(false)
expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
})
})
describe('selectColdParkedTerminalWorktrees', () => {
const nowMs = 500_000
@@ -107,6 +107,26 @@ export function canParkTerminalWorktreeRenderers(args: {
})
}
export function canManuallyParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
}): boolean {
return (
args.terminalTabs.length > 0 &&
canParkTerminalWorktreeRenderers({
...args,
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs: 0,
nowMs: 0,
coldParkDelayMs: 0
})
)
}
export function canParkTerminalTabRenderer(args: {
worktreeId: string
terminalTab: TerminalTabColdParkCandidate
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { combineTerminalWorktreeParkIds } from './use-manual-terminal-worktree-parking'
describe('combineTerminalWorktreeParkIds', () => {
it('adds manually parked worktrees without dropping automatic parks', () => {
expect(combineTerminalWorktreeParkIds(new Set(['automatic']), new Set(['manual']))).toEqual(
new Set(['automatic', 'manual'])
)
})
it('keeps the automatic set reference when there are no manual parks', () => {
const automatic = new Set(['automatic'])
expect(combineTerminalWorktreeParkIds(automatic, new Set())).toBe(automatic)
})
})
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
takeAllPendingManualTerminalWorktreeParks,
takePendingManualTerminalWorktreePark,
type ManualTerminalWorktreeParkDetail
} from '@/lib/manual-terminal-worktree-parking'
import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking'
import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
function addWorktreeId(current: ReadonlySet<string>, worktreeId: string): ReadonlySet<string> {
if (current.has(worktreeId)) {
return current
}
return new Set([...current, worktreeId])
}
function removeWorktreeId(current: ReadonlySet<string>, worktreeId: string): ReadonlySet<string> {
if (!current.has(worktreeId)) {
return current
}
const next = new Set(current)
next.delete(worktreeId)
return next
}
export function combineTerminalWorktreeParkIds(
automaticIds: ReadonlySet<string>,
manualIds: ReadonlySet<string>
): ReadonlySet<string> {
if (manualIds.size === 0) {
return automaticIds
}
return new Set([...automaticIds, ...manualIds])
}
export function useManualTerminalWorktreeParking(args: {
activeView: string
renderedActiveWorktreeId: string | null
}): ReadonlySet<string> {
const [manuallyParkedWorktreeIds, setManuallyParkedWorktreeIds] = useState<ReadonlySet<string>>(
() => new Set()
)
const parkWorktree = useCallback((worktreeId: string) => {
const state = useAppStore.getState()
const terminalTabs = state.tabsByWorktree[worktreeId] ?? []
const canPark =
canManuallyParkTerminalWorktreeRenderers({
worktreeId,
terminalTabs,
pendingStartupByTabId: state.pendingStartupByTabId,
parkingEnabled: state.settings?.terminalHiddenViewParking !== false
}) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))
if (!canPark) {
toast.warning(
translate(
'auto.components.terminalPane.useManualTerminalWorktreeParking.cannotPark',
'These terminals cannot be parked safely.'
)
)
return
}
setManuallyParkedWorktreeIds((current) => addWorktreeId(current, worktreeId))
}, [])
useEffect(() => {
const handleParkRequest = (event: Event): void => {
const worktreeId = (event as CustomEvent<ManualTerminalWorktreeParkDetail>).detail?.worktreeId
if (!worktreeId) {
return
}
takePendingManualTerminalWorktreePark(worktreeId)
parkWorktree(worktreeId)
}
window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, handleParkRequest as EventListener)
for (const worktreeId of takeAllPendingManualTerminalWorktreeParks()) {
parkWorktree(worktreeId)
}
return () =>
window.removeEventListener(
MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
handleParkRequest as EventListener
)
}, [parkWorktree])
useEffect(() => {
const revealedWorktreeId = args.renderedActiveWorktreeId
if (args.activeView !== 'terminal' || !revealedWorktreeId) {
return
}
setManuallyParkedWorktreeIds((current) => removeWorktreeId(current, revealedWorktreeId))
}, [args.activeView, args.renderedActiveWorktreeId])
return manuallyParkedWorktreeIds
}
+9
View File
@@ -4939,6 +4939,10 @@
"close": "Close",
"image": "Image",
"expand": "Expand image"
},
"WorktreeDeveloperMenu": {
"developer": "Developer",
"parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13963,6 +13967,11 @@
"undo": "Undo",
"widthOption": "{{value0}} px"
}
},
"terminalPane": {
"useManualTerminalWorktreeParking": {
"cannotPark": "These terminals cannot be parked safely."
}
}
},
"i18n": {
+9
View File
@@ -4916,6 +4916,10 @@
"close": "Cerrar",
"image": "Imagen",
"expand": "Ampliar imagen"
},
"WorktreeDeveloperMenu": {
"developer": "Developer",
"parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13940,6 +13944,11 @@
"undo": "Deshacer",
"widthOption": "{{value0}} px"
}
},
"terminalPane": {
"useManualTerminalWorktreeParking": {
"cannotPark": "These terminals cannot be parked safely."
}
}
},
"i18n": {
+9
View File
@@ -4916,6 +4916,10 @@
"close": "閉じる",
"image": "画像",
"expand": "画像を拡大"
},
"WorktreeDeveloperMenu": {
"developer": "Developer",
"parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13940,6 +13944,11 @@
"undo": "元に戻す",
"widthOption": "{{value0}} px"
}
},
"terminalPane": {
"useManualTerminalWorktreeParking": {
"cannotPark": "These terminals cannot be parked safely."
}
}
},
"i18n": {
+9
View File
@@ -4916,6 +4916,10 @@
"close": "닫기",
"image": "이미지",
"expand": "이미지 확대"
},
"WorktreeDeveloperMenu": {
"developer": "Developer",
"parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13940,6 +13944,11 @@
"undo": "실행 취소",
"widthOption": "{{value0}} px"
}
},
"terminalPane": {
"useManualTerminalWorktreeParking": {
"cannotPark": "These terminals cannot be parked safely."
}
}
},
"i18n": {
+9
View File
@@ -4916,6 +4916,10 @@
"close": "关闭",
"image": "图像",
"expand": "展开图像"
},
"WorktreeDeveloperMenu": {
"developer": "Developer",
"parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13940,6 +13944,11 @@
"undo": "撤销",
"widthOption": "{{value0}} px"
}
},
"terminalPane": {
"useManualTerminalWorktreeParking": {
"cannotPark": "These terminals cannot be parked safely."
}
}
},
"i18n": {
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
requestManualTerminalWorktreePark,
takeAllPendingManualTerminalWorktreeParks,
takePendingManualTerminalWorktreePark,
type ManualTerminalWorktreeParkDetail
} from './manual-terminal-worktree-parking'
describe('manual terminal worktree parking requests', () => {
beforeEach(() => {
takeAllPendingManualTerminalWorktreeParks()
})
it('dispatches the target worktree and records it for a late Terminal mount', () => {
const listener = vi.fn((event: Event) => {
expect((event as CustomEvent<ManualTerminalWorktreeParkDetail>).detail).toEqual({
worktreeId: 'worktree-1'
})
})
window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
requestManualTerminalWorktreePark('worktree-1')
expect(listener).toHaveBeenCalledOnce()
expect(takePendingManualTerminalWorktreePark('worktree-1')).toBe(true)
window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
})
it('ignores an empty worktree id', () => {
const listener = vi.fn()
window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
requestManualTerminalWorktreePark('')
expect(listener).not.toHaveBeenCalled()
expect(takeAllPendingManualTerminalWorktreeParks()).toEqual([])
window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
})
})
@@ -0,0 +1,29 @@
export const MANUAL_TERMINAL_WORKTREE_PARK_EVENT = 'orca-manual-terminal-worktree-park'
export type ManualTerminalWorktreeParkDetail = {
worktreeId: string
}
const pendingWorktreeIds = new Set<string>()
export function requestManualTerminalWorktreePark(worktreeId: string): void {
if (!worktreeId) {
return
}
pendingWorktreeIds.add(worktreeId)
window.dispatchEvent(
new CustomEvent<ManualTerminalWorktreeParkDetail>(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, {
detail: { worktreeId }
})
)
}
export function takePendingManualTerminalWorktreePark(worktreeId: string): boolean {
return pendingWorktreeIds.delete(worktreeId)
}
export function takeAllPendingManualTerminalWorktreeParks(): string[] {
const pending = [...pendingWorktreeIds]
pendingWorktreeIds.clear()
return pending
}