From fadb3f3730062485fbfdaa0166ff06a1bc543a37 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:15 -0700 Subject: [PATCH 001/138] perf(renderer): scope sidebar agent freshness by worktree (#8052) --- .../sidebar/useWorktreeAgentRows.ts | 23 ++-- .../worktree-agent-freshness-selector.test.ts | 105 ++++++++++++++++++ .../worktree-agent-freshness-selector.ts | 53 +++++++++ 3 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-agent-freshness-selector.ts diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 9cfc00e32b6..b2f45a8a9a0 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -16,6 +16,10 @@ import { selectRetainedAgentEntriesForWorktree, selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' +import { + createWorktreeAgentFreshnessSelector, + EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE +} from './worktree-agent-freshness-selector' export { buildWorktreeAgentRows } from './worktree-agent-rows' export { @@ -36,6 +40,10 @@ export { * selector work on high-frequency agent status pings. */ export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] { + const selectAgentFreshness = useMemo( + () => createWorktreeAgentFreshnessSelector(worktreeId), + [worktreeId] + ) const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined)) // Why: narrow the subscriptions to only THIS worktree's entries via // useShallow. Subscribing to the whole agentStatusByPaneKey map would make @@ -66,19 +74,16 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa const runtimeAgentOrchestrationByPaneKey = useAppStore( useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {})) ) - // Why: agentStatusEpoch is included in the dependency array (but not in the - // computation itself) so the memo recomputes when freshness boundaries - // expire, even if no new PTY data arrives — same rationale as - // useDashboardData. - const agentStatusEpoch = useAppStore((s) => (active ? s.agentStatusEpoch : 0)) + const agentFreshnessSignature = useAppStore((s) => + active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE + ) return useMemo(() => { if (!active) { return [] } - // Why: Date.now() is read inside the memo (not as a dep) so stale-decay - // recalculates whenever agentStatusEpoch ticks — same pattern as - // useDashboardData. + // Why: Date.now() is read inside the memo so stale-decay recalculates when + // this worktree's freshness signature changes, even without new PTY data. const now = Date.now() const entries = migrationUnsupported.length > 0 @@ -113,6 +118,6 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa ptyIdsByTabId, terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey, - agentStatusEpoch + agentFreshnessSignature ]) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts new file mode 100644 index 00000000000..b52b0dd9024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' +import { createWorktreeAgentFreshnessSelector } from './worktree-agent-freshness-selector' + +const WT_1_PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const WT_2_PANE = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222') + +function makeTab(id: string, worktreeId: string): TerminalTab { + return { + id, + worktreeId, + ptyId: null, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeEntry(paneKey: string, worktreeId: string, updatedAt: number): AgentStatusEntry { + return { + paneKey, + worktreeId, + state: 'working', + stateStartedAt: updatedAt, + updatedAt, + stateHistory: [], + prompt: 'working', + agentType: 'claude' + } +} + +function makeState(agentStatusEpoch: number, wt1UpdatedAt: number, wt2UpdatedAt: number) { + return { + agentStatusEpoch, + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'wt-1')], + 'wt-2': [makeTab('tab-2', 'wt-2')] + }, + agentStatusByPaneKey: { + [WT_1_PANE]: makeEntry(WT_1_PANE, 'wt-1', wt1UpdatedAt), + [WT_2_PANE]: makeEntry(WT_2_PANE, 'wt-2', wt2UpdatedAt) + }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {} + } +} + +describe('createWorktreeAgentFreshnessSelector', () => { + it('changes only the worktree whose row crosses the stale boundary', () => { + let now = AGENT_STATUS_STALE_AFTER_MS - 1 + const state = makeState(1, now - 1, 0) + const selectWt1 = createWorktreeAgentFreshnessSelector('wt-1', () => now) + const selectWt2 = createWorktreeAgentFreshnessSelector('wt-2', () => now) + const firstWt1 = selectWt1(state) + const firstWt2 = selectWt2(state) + + now = AGENT_STATUS_STALE_AFTER_MS + 1 + const nextState = { ...state, agentStatusEpoch: 2 } + + // The global scheduler bumps one epoch for all cards. Zustand compares the + // selected string by value, so the unrelated card now skips its render. + expect(selectWt1(nextState)).toBe(firstWt1) + expect(selectWt2(nextState)).not.toBe(firstWt2) + }) + + it('does constant work for same-epoch status-map churn', () => { + const readNow = vi.fn(() => 1_000) + const selector = createWorktreeAgentFreshnessSelector('wt-1', readNow) + const state = makeState(3, 900, 900) + const first = selector(state) + + const sameStatePing = { + ...state, + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + [WT_2_PANE]: { ...state.agentStatusByPaneKey[WT_2_PANE], updatedAt: 950 } + } + } + + expect(selector(sameStatePing)).toBe(first) + expect(readNow).toHaveBeenCalledTimes(1) + }) + + it.each(['working', 'blocked', 'waiting'] as const)( + 'wakes a %s row when it becomes stale', + (state) => { + let now = AGENT_STATUS_STALE_AFTER_MS - 1 + const initial = makeState(10, 0, now) + initial.agentStatusByPaneKey[WT_1_PANE].state = state + const selector = createWorktreeAgentFreshnessSelector('wt-1', () => now) + const fresh = selector(initial) + + now = AGENT_STATUS_STALE_AFTER_MS + 1 + + expect(selector({ ...initial, agentStatusEpoch: 11 })).not.toBe(fresh) + } + ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.ts b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.ts new file mode 100644 index 00000000000..4fa2832f8e1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-freshness-selector.ts @@ -0,0 +1,53 @@ +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import type { AppState } from '@/store/types' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { selectLiveAgentStatusEntriesForWorktree } from './worktree-agent-row-selectors' + +export const EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE = '' + +type WorktreeAgentFreshnessState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'migrationUnsupportedByPtyId' + | 'retainedAgentsByPaneKey' + | 'tabsByWorktree' +> + +function buildWorktreeAgentFreshnessSignature( + state: WorktreeAgentFreshnessState, + worktreeId: string, + now: number +): string { + let signature = '' + for (const entry of selectLiveAgentStatusEntriesForWorktree(state, worktreeId)) { + if (entry.state !== 'working' && entry.state !== 'blocked' && entry.state !== 'waiting') { + continue + } + signature += `${entry.paneKey}\0${ + isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? '1' : '0' + }\0` + } + return signature +} + +export function createWorktreeAgentFreshnessSelector( + worktreeId: string, + readNow: () => number = Date.now +): (state: WorktreeAgentFreshnessState) => string { + let cachedGlobalEpoch: number | null = null + let cachedSignature = EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE + + return (state) => { + // Why: the status slice bumps the epoch for state/freshness changes; + // same-epoch map writes are fresh same-state metadata and cannot alter decay. + if (cachedGlobalEpoch === state.agentStatusEpoch) { + return cachedSignature + } + cachedGlobalEpoch = state.agentStatusEpoch + // Why: the global epoch wakes every card, but only cards whose effective + // fresh/stale row state changed should render and rebuild their row trees. + cachedSignature = buildWorktreeAgentFreshnessSignature(state, worktreeId, readNow()) + return cachedSignature + } +} From b681a2c54a08034a45c6de0f04a0c169c5ab11fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:18 -0700 Subject: [PATCH 002/138] perf(renderer): scope agent send-target control updates (#8055) --- .../components/sidebar/WorktreeCardAgents.tsx | 12 +++-- .../worktree-card-send-target-inputs.test.ts | 44 +++++++++++++++++++ .../worktree-card-send-target-inputs.ts | 28 ++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 7bfda38d231..2a924211874 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -6,7 +6,10 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { useNow } from '@/components/dashboard/useNow' import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' -import { selectSendTargetInputs } from './worktree-card-send-target-inputs' +import { + selectSendTargetControlInputs, + selectSendTargetInputs +} from './worktree-card-send-target-inputs' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' @@ -87,8 +90,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) - const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + const { targetMode: agentSendPopoverTargetMode, agentStatusEpoch } = useAppStore( + useShallow((s) => selectSendTargetControlInputs(s, worktreeId)) + ) // Why: these five maps are read only to derive send-target eligibility, which // matters only while the send-target popover targets THIS card. Two of them // (runtimePaneTitlesByTabId, agentStatusByPaneKey) churn on every pane-title @@ -125,7 +129,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [dropAgentStatus, dismissRetainedAgent] ) - const isAgentSendTargetModeActive = agentSendPopoverTargetMode?.worktreeId === worktreeId + const isAgentSendTargetModeActive = agentSendPopoverTargetMode !== null const sendTargetsByPaneKey = useMemo(() => { void agentStatusEpoch if (!isAgentSendTargetModeActive) { diff --git a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts index 03286c4f330..93165b45436 100644 --- a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentSendPopoverTargetMode } from '@/store/slices/ui' import { + EMPTY_SEND_TARGET_CONTROL_INPUTS, EMPTY_SEND_TARGET_INPUTS, + selectSendTargetControlInputs, selectSendTargetInputs, + type SendTargetControlInputsState, type SendTargetInputsState } from './worktree-card-send-target-inputs' @@ -85,3 +88,44 @@ describe('selectSendTargetInputs', () => { expect(shallow(r1, selectSendTargetInputs(s2, 'wt-A'))).toBe(false) }) }) + +describe('selectSendTargetControlInputs', () => { + it('stays stable across global epoch changes while the picker is closed', () => { + const first = selectSendTargetControlInputs( + { agentSendPopoverTargetMode: null, agentStatusEpoch: 1 }, + 'wt-A' + ) + const afterEpoch = selectSendTargetControlInputs( + { agentSendPopoverTargetMode: null, agentStatusEpoch: 2 }, + 'wt-A' + ) + + expect(first).toBe(EMPTY_SEND_TARGET_CONTROL_INPUTS) + expect(afterEpoch).toBe(EMPTY_SEND_TARGET_CONTROL_INPUTS) + expect(shallow(first, afterEpoch)).toBe(true) + }) + + it('stays stable when the picker targets another worktree', () => { + const state: SendTargetControlInputsState = { + agentSendPopoverTargetMode: makeMode('wt-B'), + agentStatusEpoch: 3 + } + + expect(selectSendTargetControlInputs(state, 'wt-A')).toBe(EMPTY_SEND_TARGET_CONTROL_INPUTS) + }) + + it('tracks the mode and freshness epoch only for the targeted worktree', () => { + const mode = makeMode('wt-A') + const first = selectSendTargetControlInputs( + { agentSendPopoverTargetMode: mode, agentStatusEpoch: 4 }, + 'wt-A' + ) + const afterEpoch = selectSendTargetControlInputs( + { agentSendPopoverTargetMode: mode, agentStatusEpoch: 5 }, + 'wt-A' + ) + + expect(first).toEqual({ targetMode: mode, agentStatusEpoch: 4 }) + expect(shallow(first, afterEpoch)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts index a7e7efadc4e..2d123a5bcc4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts @@ -11,6 +11,16 @@ export type SendTargetInputsState = Pick< | 'runtimePaneTitlesByTabId' > +export type SendTargetControlInputsState = Pick< + AppState, + 'agentSendPopoverTargetMode' | 'agentStatusEpoch' +> + +export type SendTargetControlInputs = { + targetMode: AppState['agentSendPopoverTargetMode'] + agentStatusEpoch: number +} + // Why: shared stable reference returned whenever the send-target popover isn't // targeting this card. useShallow keeps the same result across unrelated // pane-title / agent-status churn, so idle agent bodies stop re-rendering. @@ -23,6 +33,13 @@ export const EMPTY_SEND_TARGET_INPUTS: RunningAgentTargetState = Object.freeze({ runtimePaneTitlesByTabId: {} }) +// Why: the picker mode and freshness epoch are irrelevant to every card except +// its current target. Keep inactive bodies stable across both global writes. +export const EMPTY_SEND_TARGET_CONTROL_INPUTS: SendTargetControlInputs = Object.freeze({ + targetMode: null, + agentStatusEpoch: 0 +}) + /** * Select the five maps `deriveRunningAgentSendTargets` needs — but only while * the send-target popover targets this worktree. When it doesn't, return a @@ -45,3 +62,14 @@ export function selectSendTargetInputs( runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId } } + +export function selectSendTargetControlInputs( + s: SendTargetControlInputsState, + worktreeId: string +): SendTargetControlInputs { + const targetMode = s.agentSendPopoverTargetMode + if (targetMode?.worktreeId !== worktreeId) { + return EMPTY_SEND_TARGET_CONTROL_INPUTS + } + return { targetMode, agentStatusEpoch: s.agentStatusEpoch } +} From 818e182a0c4b6d0c9a45a65854316bea35a17aab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:20 -0700 Subject: [PATCH 003/138] perf(renderer): gate worktree list review caches (#8061) --- .../src/components/sidebar/WorktreeList.tsx | 17 +--- .../worktree-list-review-cache-inputs.test.ts | 86 +++++++++++++++++++ .../worktree-list-review-cache-inputs.ts | 44 ++++++++++ 3 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e9403b41b11..54806828e43 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -122,6 +122,7 @@ import { getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs } from './visible-worktree-activity-inputs' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, useVirtualizedScrollAnchor, @@ -5268,20 +5269,8 @@ const WorktreeList = React.memo(function WorktreeList({ const cardProps = useAppStore((s) => s.worktreeCardProperties) - // PR cache is needed for PR-status grouping and when the status lane can - // show PR state on quiet/done workspace cards. - const prCache = useAppStore((s) => - groupBy === 'pr-status' || - (s.settings?.experimentalNewWorktreeCardStyle === true - ? cardProps.includes('status') - : cardProps.includes('pr')) - ? s.prCache - : null - ) - const hostedReviewCache = useAppStore((s) => - s.settings?.experimentalNewWorktreeCardStyle === true && cardProps.includes('status') - ? s.hostedReviewCache - : null + const { prCache, hostedReviewCache } = useAppStore( + useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) const settings = useAppStore((s) => s.settings) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) diff --git a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts new file mode 100644 index 00000000000..4960445c342 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import type { FolderWorkspace } from '../../../../shared/types' +import { + EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS, + selectWorktreeListReviewCacheInputs, + type WorktreeListReviewCacheState +} from './worktree-list-review-cache-inputs' + +const EMPTY_STATE: WorktreeListReviewCacheState = { + folderWorkspaces: [], + hostedReviewCache: {}, + prCache: {}, + settings: null +} + +const FOLDER_WORKSPACE = { id: 'folder-1' } as FolderWorkspace + +describe('selectWorktreeListReviewCacheInputs', () => { + it('ignores cache churn for ordinary cards outside PR-status grouping', () => { + const first = selectWorktreeListReviewCacheInputs(EMPTY_STATE, 'repo', ['pr']) + const afterCacheFill = selectWorktreeListReviewCacheInputs( + { + ...EMPTY_STATE, + prCache: { branch: {} as never }, + hostedReviewCache: { branch: {} as never } + }, + 'repo', + ['pr'] + ) + + expect(first).toBe(EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS) + expect(afterCacheFill).toBe(EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS) + expect(shallow(first, afterCacheFill)).toBe(true) + }) + + it('keeps the PR cache live for PR-status grouping', () => { + const prCache = { branch: {} as never } + const selected = selectWorktreeListReviewCacheInputs( + { ...EMPTY_STATE, prCache }, + 'pr-status', + [] + ) + + expect(selected).toEqual({ prCache, hostedReviewCache: null }) + }) + + it('keeps the PR cache live for legacy folder-card review displays', () => { + const prCache = { branch: {} as never } + const selected = selectWorktreeListReviewCacheInputs( + { ...EMPTY_STATE, folderWorkspaces: [FOLDER_WORKSPACE], prCache }, + 'repo', + ['pr'] + ) + + expect(selected).toEqual({ prCache, hostedReviewCache: null }) + }) + + it('keeps both caches live for new-style folder status displays', () => { + const prCache = { branch: {} as never } + const hostedReviewCache = { branch: {} as never } + const selected = selectWorktreeListReviewCacheInputs( + { + ...EMPTY_STATE, + folderWorkspaces: [FOLDER_WORKSPACE], + prCache, + hostedReviewCache, + settings: { experimentalNewWorktreeCardStyle: true } as never + }, + 'repo', + ['status'] + ) + + expect(selected).toEqual({ prCache, hostedReviewCache }) + }) + + it('ignores both caches when folder cards hide review presentation', () => { + const selected = selectWorktreeListReviewCacheInputs( + { ...EMPTY_STATE, folderWorkspaces: [FOLDER_WORKSPACE] }, + 'repo', + ['comment'] + ) + + expect(selected).toBe(EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts new file mode 100644 index 00000000000..9ea780c89ea --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-review-cache-inputs.ts @@ -0,0 +1,44 @@ +import type { AppState } from '@/store/types' +import type { WorktreeCardProperty } from '../../../../shared/types' +import type { WorktreeGroupBy } from './worktree-list-groups' + +export type WorktreeListReviewCacheState = Pick< + AppState, + 'folderWorkspaces' | 'hostedReviewCache' | 'prCache' | 'settings' +> + +export type WorktreeListReviewCacheInputs = { + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null +} + +export const EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS: WorktreeListReviewCacheInputs = Object.freeze( + { + prCache: null, + hostedReviewCache: null + } +) + +export function selectWorktreeListReviewCacheInputs( + state: WorktreeListReviewCacheState, + groupBy: WorktreeGroupBy, + cardProperties: readonly WorktreeCardProperty[] +): WorktreeListReviewCacheInputs { + const hasFolderWorkspaces = state.folderWorkspaces.length > 0 + const newCardStyle = state.settings?.experimentalNewWorktreeCardStyle === true + const folderCardsNeedReview = + hasFolderWorkspaces && + (newCardStyle ? cardProperties.includes('status') : cardProperties.includes('pr')) + const needsPrCache = groupBy === 'pr-status' || folderCardsNeedReview + const needsHostedReviewCache = newCardStyle && folderCardsNeedReview + + // Why: ordinary git worktree cards own entry-level subscriptions. The list + // needs whole caches only for PR grouping or synthetic folder-card displays. + if (!needsPrCache && !needsHostedReviewCache) { + return EMPTY_WORKTREE_LIST_REVIEW_CACHE_INPUTS + } + return { + prCache: needsPrCache ? state.prCache : null, + hostedReviewCache: needsHostedReviewCache ? state.hostedReviewCache : null + } +} From 74e68afbb2e3dc321aa74f0823534a304c4ae14e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:23 -0700 Subject: [PATCH 004/138] perf(renderer): share terminal agent type index (#8066) --- .../components/terminal-pane/TerminalPane.tsx | 19 ++--- .../terminal-tab-agent-type-index.test.ts | 74 +++++++++++++++++++ .../terminal-tab-agent-type-index.ts | 73 ++++++++++++++++++ 3 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 297b28da36d..57b0a692776 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -101,7 +101,6 @@ import { import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import type { AgentType } from '../../../../shared/agent-status-types' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' @@ -148,6 +147,7 @@ import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-atlas-rec import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' +import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index' const NATIVE_CHAT_ROOT_SELECTOR = '[data-native-chat-root="true"]' @@ -680,19 +680,10 @@ export default function TerminalPane({ // when Orca launched a *supported* agent here or one was detected live for the // leaf, keyed `${tabId}:${leafId}`. Carry the agent identity, not just "an // agent exists", so the gate can reject Grok et al. - // Scoped to this tab's panes (leafId → agentType) and shallow-compared so an - // unrelated tab's agent status tick doesn't re-render this pane. - const tabAgentTypeByLeaf = useAppStore( - useShallow((store) => { - const prefix = `${tabId}:` - const byLeaf: Record = {} - for (const [paneKey, entry] of Object.entries(store.agentStatusByPaneKey)) { - if (paneKey.startsWith(prefix) && entry.agentType) { - byLeaf[paneKey.slice(prefix.length)] = entry.agentType - } - } - return byLeaf - }) + // Scope to this tab's panes and reuse the shared map index so hidden tabs do + // not each rescan every agent entry on unrelated store writes. + const tabAgentTypeByLeaf = useAppStore((store) => + selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts new file mode 100644 index 00000000000..bf000ffc697 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { createTerminalTabAgentTypeSelector } from './terminal-tab-agent-type-index' + +function entry(agentType: AgentStatusEntry['agentType'], state = 'working'): AgentStatusEntry { + return { agentType, state, updatedAt: 0 } as AgentStatusEntry +} + +describe('createTerminalTabAgentTypeSelector', () => { + it('scans one global map only once across all mounted tab selectors', () => { + const onEntryVisited = vi.fn() + const select = createTerminalTabAgentTypeSelector({ onEntryVisited }) + const state = { + 'tab-1:leaf-a': entry('claude'), + 'tab-1:leaf-b': entry('codex'), + 'tab-2:leaf-c': entry('grok') + } + + expect(select(state, 'tab-1')).toEqual({ 'leaf-a': 'claude', 'leaf-b': 'codex' }) + expect(select(state, 'tab-2')).toEqual({ 'leaf-c': 'grok' }) + for (let index = 0; index < 100; index += 1) { + select(state, `hidden-tab-${index}`) + } + + expect(onEntryVisited).toHaveBeenCalledTimes(3) + }) + + it('reuses per-tab results across state-only agent transitions', () => { + const select = createTerminalTabAgentTypeSelector() + const working = { + 'tab-1:leaf-a': entry('claude', 'working'), + 'tab-2:leaf-b': entry('codex', 'working') + } + const firstTab = select(working, 'tab-1') + const secondTab = select(working, 'tab-2') + const done = { + 'tab-1:leaf-a': entry('claude', 'done'), + 'tab-2:leaf-b': entry('codex', 'done') + } + + expect(select(done, 'tab-1')).toBe(firstTab) + expect(select(done, 'tab-2')).toBe(secondTab) + }) + + it('changes only the tab whose agent identity changes', () => { + const select = createTerminalTabAgentTypeSelector() + const before = { + 'tab-1:leaf-a': entry('claude'), + 'tab-2:leaf-b': entry('codex') + } + const firstTab = select(before, 'tab-1') + const secondTab = select(before, 'tab-2') + const after = { + ...before, + 'tab-2:leaf-b': entry('grok') + } + + expect(select(after, 'tab-1')).toBe(firstTab) + expect(select(after, 'tab-2')).not.toBe(secondTab) + expect(select(after, 'tab-2')).toEqual({ 'leaf-b': 'grok' }) + }) + + it('ignores missing agent types and keys without a tab prefix', () => { + const select = createTerminalTabAgentTypeSelector() + const state = { + malformed: entry('claude'), + ':leaf-a': entry('codex'), + 'tab-1:leaf-a': entry(undefined) + } + + expect(select(state, 'tab-1')).toEqual({}) + expect(select(state, 'malformed')).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts new file mode 100644 index 00000000000..1b7693b45ab --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -0,0 +1,73 @@ +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' + +export type TerminalTabAgentTypeState = Record +export type TerminalTabAgentTypesByLeaf = Readonly> + +type SelectorDependencies = { + onEntryVisited?: (paneKey: string) => void +} + +const EMPTY_AGENT_TYPES_BY_LEAF: TerminalTabAgentTypesByLeaf = Object.freeze({}) + +function reuseRecordIfEqual( + previous: TerminalTabAgentTypesByLeaf | undefined, + next: Record +): TerminalTabAgentTypesByLeaf { + if (!previous) { + return next + } + const nextKeys = Object.keys(next) + if (Object.keys(previous).length !== nextKeys.length) { + return next + } + return nextKeys.every((key) => previous[key] === next[key]) ? previous : next +} + +export function createTerminalTabAgentTypeSelector( + dependencies: SelectorDependencies = {} +): (state: TerminalTabAgentTypeState, tabId: string) => TerminalTabAgentTypesByLeaf { + let cachedState: TerminalTabAgentTypeState | null = null + let cachedByTabId = new Map() + + return (state, tabId) => { + // Why: production writes replace this map. Its identity lets unrelated + // Zustand notifications skip the global scan entirely. + if (state !== cachedState) { + const previousByTabId = cachedByTabId + const nextByTabId = new Map>() + for (const [paneKey, entry] of Object.entries(state)) { + dependencies.onEntryVisited?.(paneKey) + if (!entry.agentType) { + continue + } + const separator = paneKey.indexOf(':') + if (separator <= 0) { + continue + } + const entryTabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + const byLeaf = nextByTabId.get(entryTabId) + if (byLeaf) { + byLeaf[leafId] = entry.agentType + } else { + nextByTabId.set(entryTabId, { [leafId]: entry.agentType }) + } + } + + const stabilizedByTabId = new Map() + for (const [entryTabId, byLeaf] of nextByTabId) { + stabilizedByTabId.set( + entryTabId, + reuseRecordIfEqual(previousByTabId.get(entryTabId), byLeaf) + ) + } + cachedByTabId = stabilizedByTabId + cachedState = state + } + return cachedByTabId.get(tabId) ?? EMPTY_AGENT_TYPES_BY_LEAF + } +} + +// Why: TerminalPane is mounted once per retained tab. Share one index so a +// store write scans the global agent map once, not once for every hidden tab. +export const selectTerminalTabAgentTypesByLeaf = createTerminalTabAgentTypeSelector() From 09196ba086887e8053d6d637292df566a0a3b2d6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:26 -0700 Subject: [PATCH 005/138] perf(renderer): scope floating workspace collections (#8071) --- .../FloatingTerminalPanel.tsx | 32 +-- .../floating-terminal-panel-inputs.test.ts | 193 ++++++++++++++++++ .../floating-terminal-panel-inputs.ts | 120 +++++++++++ 3 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.ts diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 8fa5e6a5258..706e3d76e77 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -61,12 +61,7 @@ import { ModifierDoubleTapDetector, toModifierDoubleTapEvent } from '../../../../shared/modifier-double-tap-detector' -import type { - BrowserTab as BrowserTabState, - Tab, - TabGroup, - TerminalTab -} from '../../../../shared/types' +import type { BrowserTab as BrowserTabState, Tab, TerminalTab } from '../../../../shared/types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { FloatingBrowserSlot } from './FloatingBrowserSlot' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' @@ -90,10 +85,7 @@ import { } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' -const EMPTY_TERMINAL_TABS: TerminalTab[] = [] -const EMPTY_BROWSER_TABS: BrowserTabState[] = [] -const EMPTY_GROUPS: TabGroup[] = [] -const EMPTY_UNIFIED_TABS: Tab[] = [] +import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inputs' const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) @@ -170,12 +162,8 @@ export function FloatingTerminalPanel({ onOpenChange, tourInteractionSnapshot }: FloatingTerminalPanelProps): React.JSX.Element | null { - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) - const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) - const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) - const openFiles = useAppStore((s) => s.openFiles) - const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) + const { tabs, browserTabs, groups, unifiedTabs, floatingFiles, expandedPaneByTabId } = + useAppStore(selectFloatingTerminalPanelInputs) const createTab = useAppStore((s) => s.createTab) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeTab = useAppStore((s) => s.closeTab) @@ -243,14 +231,6 @@ export function FloatingTerminalPanel({ moved: boolean } | null>(null) - const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS - const browserTabs = browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS - const groups = groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS - const unifiedTabs = unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS - const floatingFiles = useMemo( - () => openFiles.filter((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID), - [openFiles] - ) const activeGroup = useMemo( () => groups.find((group) => group.activeTabId != null) ?? @@ -413,6 +393,8 @@ export function FloatingTerminalPanel({ wasFeaturePreviouslyInteracted: tourInteractionSnapshot?.wasPreviouslyInteracted }) + // Why: this panel only queues its own editor tabs, so unrelated workspace + // file updates must not invalidate the hidden panel's close callbacks. const { saveDialogFileId, saveDialogFile, @@ -420,7 +402,7 @@ export function FloatingTerminalPanel({ handleSaveDialogSave, handleSaveDialogDiscard, handleSaveDialogCancel - } = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty }) + } = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile, markFileDirty }) const getNextQueuedEditorClose = useCallback((): string | null => { while (pendingEditorCloseQueueRef.current.length > 0) { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts new file mode 100644 index 00000000000..ff81a7e7eca --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { createFloatingTerminalPanelInputsSelector } from './floating-terminal-panel-inputs' + +const OTHER_WORKTREE_ID = 'worktree-other' + +type State = Parameters>[0] +type TerminalTab = NonNullable[number] +type BrowserTab = NonNullable[number] +type TabGroup = NonNullable[number] +type UnifiedTab = NonNullable[number] +type OpenFile = AppState['openFiles'][number] + +function terminalTab(id: string, worktreeId: string = FLOATING_TERMINAL_WORKTREE_ID): TerminalTab { + return { id, worktreeId } as TerminalTab +} + +function browserTab(id: string, worktreeId: string = FLOATING_TERMINAL_WORKTREE_ID): BrowserTab { + return { id, worktreeId } as BrowserTab +} + +function group(id: string, worktreeId: string = FLOATING_TERMINAL_WORKTREE_ID): TabGroup { + return { id, worktreeId } as TabGroup +} + +function unifiedTab(id: string, worktreeId: string = FLOATING_TERMINAL_WORKTREE_ID): UnifiedTab { + return { id, worktreeId } as UnifiedTab +} + +function openFile(id: string, worktreeId: string): OpenFile { + return { id, worktreeId } as OpenFile +} + +function state(overrides: Partial = {}): State { + return { + browserTabsByWorktree: {}, + expandedPaneByTabId: {}, + groupsByWorktree: {}, + openFiles: [], + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + ...overrides + } +} + +describe('createFloatingTerminalPanelInputsSelector', () => { + it('ignores unrelated workspace collection churn while the panel is retained', () => { + const onExpandedTabVisited = vi.fn() + const onOpenFileVisited = vi.fn() + const select = createFloatingTerminalPanelInputsSelector({ + onExpandedTabVisited, + onOpenFileVisited + }) + const floatingTab = terminalTab('floating-terminal') + const floatingBrowser = browserTab('floating-browser') + const floatingGroup = group('floating-group') + const floatingUnifiedTab = unifiedTab('floating-unified') + const floatingFile = openFile('floating-file', FLOATING_TERMINAL_WORKTREE_ID) + const initial = state({ + browserTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [floatingBrowser], + [OTHER_WORKTREE_ID]: [browserTab('other-browser', OTHER_WORKTREE_ID)] + }, + expandedPaneByTabId: { 'floating-terminal': true, 'other-terminal': true }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [floatingGroup], + [OTHER_WORKTREE_ID]: [group('other-group', OTHER_WORKTREE_ID)] + }, + openFiles: [floatingFile, openFile('other-file', OTHER_WORKTREE_ID)], + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab], + [OTHER_WORKTREE_ID]: [terminalTab('other-terminal', OTHER_WORKTREE_ID)] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [floatingUnifiedTab], + [OTHER_WORKTREE_ID]: [unifiedTab('other-unified', OTHER_WORKTREE_ID)] + } + }) + const first = select(initial) + const afterUnrelatedWrites = state({ + browserTabsByWorktree: { + ...initial.browserTabsByWorktree, + [OTHER_WORKTREE_ID]: [browserTab('other-browser-next', OTHER_WORKTREE_ID)] + }, + expandedPaneByTabId: { ...initial.expandedPaneByTabId, 'other-terminal': false }, + groupsByWorktree: { + ...initial.groupsByWorktree, + [OTHER_WORKTREE_ID]: [group('other-group-next', OTHER_WORKTREE_ID)] + }, + openFiles: [floatingFile, openFile('other-file-next', OTHER_WORKTREE_ID)], + tabsByWorktree: { + ...initial.tabsByWorktree, + [OTHER_WORKTREE_ID]: [terminalTab('other-terminal-next', OTHER_WORKTREE_ID)] + }, + unifiedTabsByWorktree: { + ...initial.unifiedTabsByWorktree, + [OTHER_WORKTREE_ID]: [unifiedTab('other-unified-next', OTHER_WORKTREE_ID)] + } + }) + + expect(select(afterUnrelatedWrites)).toBe(first) + expect(select({ ...afterUnrelatedWrites })).toBe(first) + expect(onOpenFileVisited).toHaveBeenCalledTimes(4) + expect(onExpandedTabVisited).toHaveBeenCalledTimes(2) + }) + + it('updates each collection when the floating workspace itself changes', () => { + const select = createFloatingTerminalPanelInputsSelector() + let currentState = state() + let previous = select(currentState) + + currentState = { + ...currentState, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab('terminal')] } + } + let selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.tabs.map((tab) => tab.id)).toEqual(['terminal']) + previous = selected + + currentState = { + ...currentState, + browserTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab('browser')] + } + } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.browserTabs.map((tab) => tab.id)).toEqual(['browser']) + previous = selected + + currentState = { + ...currentState, + groupsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [group('group')] } + } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.groups.map((entry) => entry.id)).toEqual(['group']) + previous = selected + + currentState = { + ...currentState, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [unifiedTab('unified')] + } + } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.unifiedTabs.map((tab) => tab.id)).toEqual(['unified']) + previous = selected + + currentState = { + ...currentState, + openFiles: [openFile('file', FLOATING_TERMINAL_WORKTREE_ID)] + } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.floatingFiles.map((file) => file.id)).toEqual(['file']) + previous = selected + + currentState = { ...currentState, expandedPaneByTabId: { terminal: true } } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.expandedPaneByTabId).toEqual({ terminal: true }) + previous = selected + + currentState = { ...currentState, expandedPaneByTabId: { terminal: false } } + selected = select(currentState) + expect(selected).not.toBe(previous) + expect(selected.expandedPaneByTabId).toEqual({}) + }) + + it('excludes non-floating files and pane expansion state', () => { + const select = createFloatingTerminalPanelInputsSelector() + const selected = select( + state({ + expandedPaneByTabId: { floating: false, other: true }, + openFiles: [ + openFile('floating-file', FLOATING_TERMINAL_WORKTREE_ID), + openFile('other-file', OTHER_WORKTREE_ID) + ], + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab('floating')], + [OTHER_WORKTREE_ID]: [terminalTab('other', OTHER_WORKTREE_ID)] + } + }) + ) + + expect(selected.floatingFiles.map((file) => file.id)).toEqual(['floating-file']) + expect(selected.expandedPaneByTabId).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.ts new file mode 100644 index 00000000000..f77cc1d7581 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.ts @@ -0,0 +1,120 @@ +import type { AppState } from '@/store/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +type FloatingTerminalPanelState = Pick< + AppState, + | 'browserTabsByWorktree' + | 'expandedPaneByTabId' + | 'groupsByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' +> + +export type FloatingTerminalPanelInputs = { + browserTabs: NonNullable + expandedPaneByTabId: Record + floatingFiles: AppState['openFiles'] + groups: NonNullable + tabs: NonNullable + unifiedTabs: NonNullable +} + +type SelectorDependencies = { + onExpandedTabVisited?: (tabId: string) => void + onOpenFileVisited?: (fileId: string) => void +} + +const EMPTY_BROWSER_TABS: FloatingTerminalPanelInputs['browserTabs'] = [] +const EMPTY_EXPANDED_PANES: FloatingTerminalPanelInputs['expandedPaneByTabId'] = {} +const EMPTY_FILES: FloatingTerminalPanelInputs['floatingFiles'] = [] +const EMPTY_GROUPS: FloatingTerminalPanelInputs['groups'] = [] +const EMPTY_TABS: FloatingTerminalPanelInputs['tabs'] = [] +const EMPTY_UNIFIED_TABS: FloatingTerminalPanelInputs['unifiedTabs'] = [] + +function reuseArrayIfEqual(previous: T[], next: T[]): T[] { + return previous.length === next.length && next.every((value, index) => previous[index] === value) + ? previous + : next +} + +function reuseExpandedPanesIfEqual( + previous: Record, + next: Record +): Record { + const nextKeys = Object.keys(next) + if (Object.keys(previous).length !== nextKeys.length) { + return next + } + return nextKeys.every((key) => previous[key] === next[key]) ? previous : next +} + +export function createFloatingTerminalPanelInputsSelector( + dependencies: SelectorDependencies = {} +): (state: FloatingTerminalPanelState) => FloatingTerminalPanelInputs { + let openFilesSource: AppState['openFiles'] | null = null + let floatingFiles = EMPTY_FILES + let expandedTabsSource: FloatingTerminalPanelInputs['tabs'] | null = null + let expandedPanesSource: AppState['expandedPaneByTabId'] | null = null + let expandedPaneByTabId = EMPTY_EXPANDED_PANES + let previous: FloatingTerminalPanelInputs | null = null + + return (state) => { + const tabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS + const browserTabs = + state.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS + const groups = state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS + const unifiedTabs = + state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS + + if (state.openFiles !== openFilesSource) { + const nextFloatingFiles = [] as AppState['openFiles'] + for (const file of state.openFiles) { + dependencies.onOpenFileVisited?.(file.id) + if (file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + nextFloatingFiles.push(file) + } + } + floatingFiles = reuseArrayIfEqual(floatingFiles, nextFloatingFiles) + openFilesSource = state.openFiles + } + + if (tabs !== expandedTabsSource || state.expandedPaneByTabId !== expandedPanesSource) { + const nextExpandedPaneByTabId: Record = {} + for (const tab of tabs) { + dependencies.onExpandedTabVisited?.(tab.id) + if (state.expandedPaneByTabId[tab.id] === true) { + nextExpandedPaneByTabId[tab.id] = true + } + } + expandedPaneByTabId = reuseExpandedPanesIfEqual(expandedPaneByTabId, nextExpandedPaneByTabId) + expandedTabsSource = tabs + expandedPanesSource = state.expandedPaneByTabId + } + + if ( + previous?.tabs === tabs && + previous.browserTabs === browserTabs && + previous.groups === groups && + previous.unifiedTabs === unifiedTabs && + previous.floatingFiles === floatingFiles && + previous.expandedPaneByTabId === expandedPaneByTabId + ) { + return previous + } + + previous = { + browserTabs, + expandedPaneByTabId, + floatingFiles, + groups, + tabs, + unifiedTabs + } + return previous + } +} + +// Why: the closed floating workspace retains its pane tree. Scope its one +// subscription so ordinary workspace writes do not rerender that hidden tree. +export const selectFloatingTerminalPanelInputs = createFloatingTerminalPanelInputsSelector() From 106009a90c3f1e84ae89fc69fb28cf44e09d6776 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:28 -0700 Subject: [PATCH 006/138] perf(renderer): index terminal unified tab reads (#8076) --- .../components/terminal-pane/TerminalPane.tsx | 18 +++---- .../terminal-unified-tab-lookup.test.ts | 32 ++++++++---- .../terminal-unified-tab-lookup.ts | 51 +++++++++++-------- 3 files changed, 61 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 57b0a692776..2231ba66821 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -180,7 +180,10 @@ import { subscribeTerminalPaneAttention } from './terminal-pane-attention-subscriptions' import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup' -import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup' +import { + getCachedTerminalGroupIdForWorktree, + getCachedUnifiedTerminalTabForWorktree +} from './terminal-unified-tab-lookup' import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' import { useRepoById } from '@/store/selectors' import { @@ -655,23 +658,18 @@ export default function TerminalPane({ // communicates the presence-lock inside the chat surface instead (U9/R8). const unifiedTabId = useAppStore( (store) => - (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'terminal' && t.entityId === tabId - )?.id + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.id ) const isChatViewMode = useAppStore( (store) => - (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'terminal' && t.entityId === tabId - )?.viewMode === 'chat' + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.viewMode === 'chat' ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) const effectiveChatViewMode = nativeChatEnabled && isChatViewMode const unifiedTabLabel = useAppStore( (store) => - (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'terminal' && t.entityId === tabId - )?.label + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.label ) const runtimePaneTitlesByPaneId = useAppStore( useShallow((store) => store.runtimePaneTitlesByTabId[tabId] ?? {}) diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts index 998f25e11e0..18872bf04bb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/types' -import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup' +import { + getCachedTerminalGroupIdForWorktree, + getCachedUnifiedTerminalTabForWorktree +} from './terminal-unified-tab-lookup' function makeTerminalTab(entityId: string, groupId: string): Tab { return { @@ -45,8 +48,8 @@ function iterableTabs(tabs: Tab[]): { } } -describe('getCachedTerminalGroupIdForWorktree', () => { - it('reuses the terminal group lookup while the unified tab array is unchanged', () => { +describe('terminal unified tab lookup', () => { + it('shares one terminal lookup across all pane field and group reads', () => { const tabs = [ makeEditorTab('editor-1'), ...Array.from({ length: 200 }, (_, index) => @@ -56,14 +59,23 @@ describe('getCachedTerminalGroupIdForWorktree', () => { const { value, iterator } = iterableTabs(tabs) const unifiedTabsByWorktree = { 'wt-1': value } - expect(getCachedTerminalGroupIdForWorktree(unifiedTabsByWorktree, 'wt-1', 'terminal-199')).toBe( - 'group-3' - ) + for (let index = 0; index < 200; index += 1) { + const terminalTabId = `terminal-${index}` + expect( + getCachedUnifiedTerminalTabForWorktree(unifiedTabsByWorktree, 'wt-1', terminalTabId) + ).toBe(tabs[index + 1]) + expect( + getCachedUnifiedTerminalTabForWorktree(unifiedTabsByWorktree, 'wt-1', terminalTabId)?.id + ).toBe(terminalTabId) + expect( + getCachedUnifiedTerminalTabForWorktree(unifiedTabsByWorktree, 'wt-1', terminalTabId)?.label + ).toBe(terminalTabId) + } expect(getCachedTerminalGroupIdForWorktree(unifiedTabsByWorktree, 'wt-1', 'terminal-0')).toBe( 'group-0' ) expect( - getCachedTerminalGroupIdForWorktree(unifiedTabsByWorktree, 'wt-1', 'editor-1') + getCachedUnifiedTerminalTabForWorktree(unifiedTabsByWorktree, 'wt-1', 'editor-1') ).toBeNull() expect(iterator).toHaveBeenCalledTimes(1) @@ -73,9 +85,9 @@ describe('getCachedTerminalGroupIdForWorktree', () => { const first = iterableTabs([makeTerminalTab('terminal-1', 'group-a')]) const second = iterableTabs([makeTerminalTab('terminal-1', 'group-b')]) - expect(getCachedTerminalGroupIdForWorktree({ 'wt-1': first.value }, 'wt-1', 'terminal-1')).toBe( - 'group-a' - ) + expect( + getCachedUnifiedTerminalTabForWorktree({ 'wt-1': first.value }, 'wt-1', 'terminal-1')?.groupId + ).toBe('group-a') expect( getCachedTerminalGroupIdForWorktree({ 'wt-1': second.value }, 'wt-1', 'terminal-1') ).toBe('group-b') diff --git a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts index 837911e9642..2113121b29f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.ts @@ -1,29 +1,40 @@ import type { Tab } from '../../../../shared/types' -const terminalGroupLookupByUnifiedTabs = new WeakMap>() +const terminalTabLookupByUnifiedTabs = new WeakMap>() + +export function getCachedUnifiedTerminalTabForWorktree( + unifiedTabsByWorktree: Record, + worktreeId: string, + terminalTabId: string +): Tab | null { + const unifiedTabs = unifiedTabsByWorktree[worktreeId] + if (!unifiedTabs) { + return null + } + + let lookup = terminalTabLookupByUnifiedTabs.get(unifiedTabs) + if (!lookup) { + // Why: every retained TerminalPane reads this tab on every store update. + // Share one immutable-array index instead of repeating linear scans. + lookup = new Map() + for (const tab of unifiedTabs) { + if (tab.contentType === 'terminal') { + lookup.set(tab.entityId, tab) + } + } + terminalTabLookupByUnifiedTabs.set(unifiedTabs, lookup) + } + + return lookup.get(terminalTabId) ?? null +} export function getCachedTerminalGroupIdForWorktree( unifiedTabsByWorktree: Record, worktreeId: string, terminalTabId: string ): string | null { - const unifiedTabs = unifiedTabsByWorktree[worktreeId] - if (!unifiedTabs) { - return null - } - - let lookup = terminalGroupLookupByUnifiedTabs.get(unifiedTabs) - if (!lookup) { - // Why: every mounted TerminalPane asks for its owning group on store updates. - // Cache by immutable tab-array ref so 200 panes do not repeat the same scan. - lookup = new Map() - for (const tab of unifiedTabs) { - if (tab.contentType === 'terminal') { - lookup.set(tab.entityId, tab.groupId) - } - } - terminalGroupLookupByUnifiedTabs.set(unifiedTabs, lookup) - } - - return lookup.get(terminalTabId) ?? null + return ( + getCachedUnifiedTerminalTabForWorktree(unifiedTabsByWorktree, worktreeId, terminalTabId) + ?.groupId ?? null + ) } From 65b7d9a532ac912a1d44cc295d7d8e986e7063ae Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:31 -0700 Subject: [PATCH 007/138] perf(renderer): compact ignored Cursor title effects (#8080) --- .../terminal-pane/pty-transport.test.ts | 74 +++++++++++++++++++ .../components/terminal-pane/pty-transport.ts | 71 +++++++++++------- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index b676fd2fdfe..6a9e8c41bd6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -380,6 +380,80 @@ describe('createIpcPtyTransport', () => { } }) + it('compacts ignored Cursor native titles into one deferred drain', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const processor = createPtyOutputProcessor({ onTitleChange }) + const callbacks = { onData: vi.fn() } + const ignoredTitles = Array.from({ length: 4_096 }, () => '\x1b]0;Cursor Agent\x07').join('') + + processor.processData(ignoredTitles, callbacks) + + expect(vi.getTimerCount()).toBe(1) + await vi.runOnlyPendingTimersAsync() + + expect(vi.getTimerCount()).toBe(0) + expect(onTitleChange).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('lets an ignored Cursor native title clear a pending stale-title fallback', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onAgentBecameIdle = vi.fn() + const processor = createPtyOutputProcessor({ + onTitleChange: vi.fn(), + onAgentBecameIdle, + onAgentBecameWorking: vi.fn() + }) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]0;⠋ Cursor Agent\x07', callbacks) + await vi.advanceTimersByTimeAsync(0) + processor.processData('plain output\r\n', callbacks) + await vi.advanceTimersByTimeAsync(0) + + processor.processData('\x1b]0;Cursor Agent\x07', callbacks) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(3_000) + + expect(onAgentBecameIdle).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('re-arms stale-title fallback after a later title-free output scan', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const processor = createPtyOutputProcessor({ + onTitleChange, + onAgentBecameIdle: vi.fn(), + onAgentBecameWorking: vi.fn() + }) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]0;⠋ Cursor Agent\x07', callbacks) + await vi.advanceTimersByTimeAsync(0) + onTitleChange.mockClear() + processor.processData('\x1b]0;Cursor Agent\x07', callbacks) + processor.processData('plain output\r\n', callbacks) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(3_000) + + expect(onTitleChange).toHaveBeenCalledWith('Cursor Agent', 'Cursor Agent') + } finally { + vi.useRealTimers() + } + }) + it('preserves stale-title detection after compacting deferred side effects', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f2c107e441b..0aacaf2e446 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -99,11 +99,35 @@ type ProcessPtyOutputOptions = { type PendingPtySideEffect = { payloads: ProcessedAgentStatusChunk['payloads'] titles: string[] - scannedForTitles: boolean + titleScanEffect: 'none' | 'stale-probe' | 'ignored-cursor-native' containsBell: boolean suppressAttentionEvents: boolean } +function isIgnoredCursorNativeTitle(title: string): boolean { + return title.trim().toLowerCase() === 'cursor agent' +} + +function removeIgnoredCursorNativeTitles(titles: string[]): boolean { + let writeIndex = 0 + let removed = false + for (let readIndex = 0; readIndex < titles.length; readIndex += 1) { + const title = titles[readIndex] + if (isIgnoredCursorNativeTitle(title)) { + removed = true + continue + } + if (writeIndex !== readIndex) { + titles[writeIndex] = title + } + writeIndex += 1 + } + if (removed) { + titles.length = writeIndex + } + return removed +} + export function createPtyOutputProcessor({ onTitleChange, onBell, @@ -167,21 +191,6 @@ export function createPtyOutputProcessor({ } function applyObservedTerminalTitle(title: string, suppressAgentTracker = false): void { - // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" - // and it re-emits that title many times per turn (on every internal redraw) - // even while it's actively working. Orca drives the cursor spinner/unread - // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready" - // frames from the hook server (see src/main/index.ts). If we let cursor's - // bare title through, it lands in `runtimePaneTitlesByTabId` — where - // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid - // within a second of the spinner appearing. Dropping the bare title before - // it reaches the store leaves the synthesized frame as the last-applied - // state until the next hook event overwrites it. Match is literal (trimmed, - // case-insensitive) so any task/chat title cursor auto-generates still - // passes through unchanged. - if (title.trim().toLowerCase() === 'cursor agent') { - return - } lastEmittedTitle = normalizeTerminalTitle(title) onTitleChange?.(lastEmittedTitle, title) if (!suppressAgentTracker) { @@ -218,7 +227,9 @@ export function createPtyOutputProcessor({ next.payloads.length === 0 && !next.containsBell ) { - prior.scannedForTitles ||= next.scannedForTitles + // Why: for adjacent no-op scans, only the latest event decides whether + // stale-title detection should remain cleared or be re-armed. + prior.titleScanEffect = next.titleScanEffect pendingWorkingTitleSideEffects += workingTitleCount return } @@ -233,6 +244,9 @@ export function createPtyOutputProcessor({ ): void { const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]')) const titles = scannedForTitles ? extractAllOscTitles(data) : [] + // Why: Cursor emits this ignored title on every redraw; keep one ordered + // queue fact instead of one allocation and drain slot per native frame. + const ignoredCursorNativeTitle = removeIgnoredCursorNativeTitles(titles) const deliveredPayloads = onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : [] const containsBell = Boolean( @@ -246,6 +260,11 @@ export function createPtyOutputProcessor({ (isWorkingTitle(lastEmittedTitle) || pendingWorkingTitleSideEffects > 0) ) const shouldEmitEmptyTitleScan = scannedForTitles || needsStaleTitleProbe + const emptyTitleScanEffect: PendingPtySideEffect['titleScanEffect'] = ignoredCursorNativeTitle + ? 'ignored-cursor-native' + : shouldEmitEmptyTitleScan + ? 'stale-probe' + : 'none' if (!shouldEmitEmptyTitleScan && deliveredPayloads.length === 0 && !containsBell) { return } @@ -257,7 +276,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [], titles: [], - scannedForTitles: shouldEmitEmptyTitleScan, + titleScanEffect: emptyTitleScanEffect, containsBell, suppressAttentionEvents }) @@ -266,7 +285,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [payload], titles: [], - scannedForTitles: false, + titleScanEffect: 'none', containsBell: false, suppressAttentionEvents }) @@ -275,7 +294,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [], titles: [], - scannedForTitles: shouldEmitEmptyTitleScan, + titleScanEffect: emptyTitleScanEffect, containsBell: false, suppressAttentionEvents }) @@ -284,7 +303,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [], titles: [title], - scannedForTitles, + titleScanEffect: 'none', containsBell: false, suppressAttentionEvents }) @@ -293,7 +312,7 @@ export function createPtyOutputProcessor({ enqueuePtySideEffect({ payloads: [], titles: [], - scannedForTitles: false, + titleScanEffect: 'none', containsBell: true, suppressAttentionEvents }) @@ -334,7 +353,7 @@ export function createPtyOutputProcessor({ onAgentStatus(payload) } } - processObservedTitles(next.titles, next.scannedForTitles, next.suppressAttentionEvents) + processObservedTitles(next.titles, next.titleScanEffect, next.suppressAttentionEvents) if (onBell && next.containsBell) { onBell() } @@ -369,7 +388,7 @@ export function createPtyOutputProcessor({ function processObservedTitles( titles: string[], - scannedForTitles: boolean, + titleScanEffect: PendingPtySideEffect['titleScanEffect'], suppressAgentTracker: boolean ): void { if (!onTitleChange) { @@ -384,8 +403,10 @@ export function createPtyOutputProcessor({ for (const title of titles) { applyObservedTerminalTitle(title, suppressAgentTracker) } + } else if (titleScanEffect === 'ignored-cursor-native') { + clearStaleTitleTimer() } else if ( - scannedForTitles && + titleScanEffect === 'stale-probe' && !suppressAgentTracker && lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working' From e085a84df822c52faca1be1f239cf7450632f5e9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:44 -0700 Subject: [PATCH 008/138] perf(renderer): keep pre-handler PTY buffering linear (#8082) --- .../pty-pre-handler-buffer.test.ts | 74 +++++++++++++++++++ .../terminal-pane/pty-pre-handler-buffer.ts | 42 ++++++++--- 2 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts new file mode 100644 index 00000000000..7d95304ba90 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + bufferPreHandlerPtyData, + clearPreHandlerPtyState, + drainPreHandlerPtyData +} from './pty-pre-handler-buffer' + +const RESCAN_PTY_ID = 'pty-pre-handler-rescan' +const TRIM_PTY_ID = 'pty-pre-handler-trim' + +describe('pre-handler PTY buffer', () => { + afterEach(() => { + clearPreHandlerPtyState(RESCAN_PTY_ID) + clearPreHandlerPtyState(TRIM_PTY_ID) + }) + + it('does not rescan historical chunks while buffering small startup output', () => { + const originalReduce = Array.prototype.reduce + + try { + Object.defineProperty(Array.prototype, 'reduce', { + configurable: true, + writable: true, + value() { + throw new Error('Array.reduce should not be used by the pre-handler PTY buffer') + } + }) + for (let index = 0; index < 4_096; index += 1) { + bufferPreHandlerPtyData(RESCAN_PTY_ID, 'x') + } + } finally { + Object.defineProperty(Array.prototype, 'reduce', { + configurable: true, + writable: true, + value: originalReduce + }) + } + + const drained: string[] = [] + drainPreHandlerPtyData(RESCAN_PTY_ID, (data) => drained.push(data)) + expect(drained).toHaveLength(4_096) + }) + + it('does not shift the live array while trimming a capped backlog', () => { + const originalShift = Array.prototype.shift + const originalWarn = console.warn + + try { + console.warn = () => {} + Object.defineProperty(Array.prototype, 'shift', { + configurable: true, + writable: true, + value() { + throw new Error('Array.shift should not be used by the pre-handler PTY buffer') + } + }) + for (let index = 0; index < 2_048; index += 1) { + bufferPreHandlerPtyData(TRIM_PTY_ID, 'x'.repeat(1_024)) + } + } finally { + console.warn = originalWarn + Object.defineProperty(Array.prototype, 'shift', { + configurable: true, + writable: true, + value: originalShift + }) + } + + const drained: string[] = [] + drainPreHandlerPtyData(TRIM_PTY_ID, (data) => drained.push(data)) + expect(drained).toHaveLength(512) + expect(drained.join('')).toHaveLength(512 * 1_024) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index 23c4661adfd..c3d12fed9fb 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -7,7 +7,13 @@ type BufferedPreHandlerPtyData = { meta?: PtyDataMeta } -const preHandlerPtyData = new Map() +type BufferedPreHandlerPtyState = { + chunks: BufferedPreHandlerPtyData[] + head: number + bytes: number +} + +const preHandlerPtyData = new Map() const preHandlerPtyExit = new Map() // Why: Windows startup commands can emit output before pty:spawn resolves and @@ -36,21 +42,32 @@ export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyD meta && chunk.data.length !== data.length && typeof meta.rawLength === 'number' ? { ...meta, rawLength: chunk.bytes } : meta - const chunks = preHandlerPtyData.get(ptyId) ?? [] - chunks.push({ + let state = preHandlerPtyData.get(ptyId) + if (!state) { + state = { chunks: [], head: 0, bytes: 0 } + preHandlerPtyData.set(ptyId, state) + } + state.chunks.push({ data: chunk.data, bytes: chunk.bytes, ...(bufferedMeta ? { meta: bufferedMeta } : {}) }) - let totalBytes = chunks.reduce((total, entry) => total + entry.bytes, 0) - while (totalBytes > PRE_HANDLER_PTY_DATA_MAX_BYTES && chunks.length > 1) { - totalBytes -= chunks.shift()?.bytes ?? 0 + state.bytes += chunk.bytes + // Why: a missing handler can accumulate many small chunks; a stored total + // and head index keep that failure path linear instead of rescanning/shifting. + while (state.bytes > PRE_HANDLER_PTY_DATA_MAX_BYTES && state.head < state.chunks.length - 1) { + state.bytes -= state.chunks[state.head].bytes + state.chunks[state.head] = { data: '', bytes: 0 } + state.head += 1 } - preHandlerPtyData.set(ptyId, chunks) - if (totalBytes > PRE_HANDLER_PTY_DATA_WARN_BYTES && !warnedLostHandlerPtyIds.has(ptyId)) { + if (state.head > 0 && state.head * 2 >= state.chunks.length) { + state.chunks.splice(0, state.head) + state.head = 0 + } + if (state.bytes > PRE_HANDLER_PTY_DATA_WARN_BYTES && !warnedLostHandlerPtyIds.has(ptyId)) { warnedLostHandlerPtyIds.add(ptyId) console.warn( - `[pty] ${ptyId}: ${totalBytes} bytes buffered with no registered data handler; ` + + `[pty] ${ptyId}: ${state.bytes} bytes buffered with no registered data handler; ` + 'the owning pane may have lost its handler to a detach/attach race' ) } @@ -60,13 +77,14 @@ export function drainPreHandlerPtyData( ptyId: string, handler: (data: string, meta?: PtyDataMeta) => void ): void { - const chunks = preHandlerPtyData.get(ptyId) + const state = preHandlerPtyData.get(ptyId) warnedLostHandlerPtyIds.delete(ptyId) - if (!chunks) { + if (!state) { return } preHandlerPtyData.delete(ptyId) - for (const chunk of chunks) { + for (let index = state.head; index < state.chunks.length; index += 1) { + const chunk = state.chunks[index] handler(chunk.data, chunk.meta) } } From e5be88342d1e85dd625d60e82118f455c8eafbbd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:47 -0700 Subject: [PATCH 009/138] perf(renderer): reuse connection context indexes (#8085) * perf(renderer): reuse connection context indexes * perf(renderer): reuse connection indexes in tab bars * perf(renderer): reuse worktree index for diff comments * perf(renderer): index remaining diff comment selectors --- .../components/editor/CombinedDiffViewer.tsx | 5 +- .../src/components/editor/DiffSectionItem.tsx | 4 +- .../src/components/editor/DiffViewer.tsx | 4 +- .../components/editor/EditorPanelHeader.tsx | 5 +- .../src/components/editor/MonacoEditor.tsx | 4 +- .../components/editor/RichMarkdownEditor.tsx | 26 ++---- .../right-sidebar/SourceControl.tsx | 7 +- .../components/tab-bar/QuickLaunchButton.tsx | 11 +-- .../tab-bar/TabBar.context-menu.test.ts | 6 ++ .../src/components/tab-bar/TabBar.tsx | 25 +++--- .../src/lib/connection-context.test.ts | 52 ++++++++++- src/renderer/src/lib/connection-context.ts | 8 +- src/renderer/src/store/selectors.ts | 62 ++----------- .../worktree-diff-comments-selector.test.ts | 87 +++++++++++++++++++ .../store/worktree-diff-comments-selector.ts | 24 +++++ src/renderer/src/store/worktree-repo-index.ts | 61 +++++++++++++ 16 files changed, 279 insertions(+), 112 deletions(-) create mode 100644 src/renderer/src/store/worktree-diff-comments-selector.test.ts create mode 100644 src/renderer/src/store/worktree-diff-comments-selector.ts create mode 100644 src/renderer/src/store/worktree-repo-index.ts diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 8f9eee91a8e..9ae116de477 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -21,6 +21,7 @@ import { setWithLRU } from '@/lib/scroll-cache' import { getConnectionIdForFile } from '@/lib/connection-context' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { formatDiffComments } from '@/lib/diff-comments-format' @@ -234,7 +235,9 @@ export default function CombinedDiffViewer({ const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) const updateSettings = useAppStore((s) => s.updateSettings) const clearDiffComments = useAppStore((s) => s.clearDiffComments) - const diffCommentsForWorktree = useAppStore((s) => s.getDiffComments(file.worktreeId)) + const diffCommentsForWorktree = useAppStore((s) => + selectWorktreeDiffCommentsOrEmpty(s, file.worktreeId) + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId]) const isDark = settings?.theme === 'dark' || diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index ffb1aa36b9b..a070c38cb66 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -13,7 +13,7 @@ import { monaco } from '@/lib/monaco-setup' import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { computeDiffEditorFontSize } from '@/lib/editor-font-zoom' -import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import { useDiffCommentDecorator, type DecoratedDiffComment @@ -106,7 +106,7 @@ export function DiffSectionItem({ // memo. Selecting a fresh `.filter(...)` result would invalidate on every // store change and cause needless re-renders of this section. const allDiffComments = useAppStore((s): DiffComment[] | undefined => - worktreeId ? findWorktreeById(s.worktreesByRepo, worktreeId)?.diffComments : undefined + selectWorktreeDiffComments(s, worktreeId) ) const diffComments = useMemo( () => (allDiffComments ?? []).filter((c) => c.filePath === section.path && isDiffComment(c)), diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 9aae498c1c1..c119abee471 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -6,7 +6,7 @@ import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache' import { monaco } from '@/lib/monaco-setup' import { computeDiffEditorFontSize } from '@/lib/editor-font-zoom' import { useContextualCopySetup } from './useContextualCopySetup' -import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' import { @@ -57,7 +57,7 @@ export default function DiffViewer({ // identity only changes when diffComments actually changes on this worktree. // Filtering by relativePath happens in a memo below. const allDiffComments = useAppStore((s): DiffComment[] | undefined => - worktreeId ? findWorktreeById(s.worktreesByRepo, worktreeId)?.diffComments : undefined + selectWorktreeDiffComments(s, worktreeId) ) const diffComments = useMemo( () => (allDiffComments ?? []).filter((c) => c.filePath === relativePath && isDiffComment(c)), diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 23ce653055f..6cbb2b6f69b 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' +import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import type { OpenFile } from '@/store/slices/editor' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import EditorViewToggle, { @@ -81,7 +82,9 @@ export function EditorPanelHeader({ onToggleMarkdownFrontmatter, onExportMarkdownToPdf }: EditorPanelHeaderProps): React.JSX.Element { - const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId)) + const diffComments = useAppStore((s) => + selectWorktreeDiffCommentsOrEmpty(s, activeFile.worktreeId) + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId]) const diffWordWrap = useAppStore((s) => s.settings?.diffWordWrap === true) const updateSettings = useAppStore((s) => s.updateSettings) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index d99af690af5..6c608ceefa1 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -33,7 +33,7 @@ import { type MarkdownDocLinkDecorationController } from './monaco-markdown-doc-link-decorations' import { buildGitConflictDecorations, hasGitConflictMarkers } from './monaco-conflict-decorations' -import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' import type { DiffComment } from '../../../../shared/types' import { isMarkdownComment } from '@/lib/diff-comment-compat' import { formatMarkdownReviewNotes, type MarkdownReviewNote } from '@/lib/markdown-review-notes' @@ -140,7 +140,7 @@ export default function MonacoEditor({ const scrollToDiffCommentId = useAppStore((s) => s.scrollToDiffCommentId) const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId) const allDiffComments = useAppStore((s): DiffComment[] | undefined => - worktreeId ? findWorktreeById(s.worktreesByRepo, worktreeId)?.diffComments : undefined + selectWorktreeDiffComments(s, worktreeId) ) const editorFontSize = computeEditorFontSize( settings?.terminalFontSize ?? 13, diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 94e0835ea5a..c5954052906 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' +import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' +import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { useLocalImagePick } from './useLocalImagePick' import { useRichMarkdownSearch } from './useRichMarkdownSearch' import type { LinkBubbleState } from './RichMarkdownLinkBubble' @@ -81,24 +83,12 @@ export default function RichMarkdownEditor({ const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) const updateDiffComment = useAppStore((s) => s.updateDiffComment) const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments) - const allDiffComments = useAppStore((s): DiffComment[] | undefined => { - for (const list of Object.values(s.worktreesByRepo)) { - const worktree = list.find((candidate) => candidate.id === worktreeId) - if (worktree) { - return worktree.diffComments - } - } - return undefined - }) - const worktreeRoot = useAppStore((s) => { - for (const list of Object.values(s.worktreesByRepo)) { - const wt = list.find((w) => w.id === worktreeId) - if (wt) { - return wt.path - } - } - return null - }) + const allDiffComments = useAppStore((s): DiffComment[] | undefined => + selectWorktreeDiffComments(s, worktreeId) + ) + const worktreeRoot = useAppStore( + (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null + ) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 6a863e9a36d..eee38d1eb75 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -30,6 +30,7 @@ import { type LucideIcon } from 'lucide-react' import { useAppStore } from '@/store' +import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import { isSyncPushStageError, resolveRemoteOperationErrorMessage @@ -906,11 +907,13 @@ function SourceControlInner(): React.JSX.Element { const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) // Why: pass activeWorktreeId directly (even when null/undefined) so the - // slice's getDiffComments returns its stable EMPTY_COMMENTS sentinel. An + // selector returns its stable empty sentinel. An // inline `[]` fallback would allocate a new array each store update, break // Zustand's Object.is equality, and cause this component plus the // diffCommentCountByPath memo to churn on every unrelated store change. - const diffCommentsForActive = useAppStore((s) => s.getDiffComments(activeWorktreeId)) + const diffCommentsForActive = useAppStore((s) => + selectWorktreeDiffCommentsOrEmpty(s, activeWorktreeId) + ) const diffCommentCount = diffCommentsForActive.length // Why: per-file counts are fed into each UncommittedEntryRow so a comment // badge can appear next to the status letter. Compute once per render so diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 84681bbc8cc..a439a8b67c2 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' +import { getConnectionIdFromState } from '@/lib/connection-context' import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' @@ -103,15 +104,7 @@ function QuickLaunchAgentMenuItemsInner({ // snapshot via getState()). This ensures the component re-renders when the // SSH connection state changes. Returns undefined when the worktree isn't // found (store not hydrated), null for local repos, string for remote. - const connectionId = useAppStore((s) => { - const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat() - const worktree = allWorktrees.find((w) => w.id === worktreeId) - if (!worktree) { - return undefined - } - const repo = s.repos?.find((r) => r.id === worktree.repoId) - return repo?.connectionId ?? null - }) + const connectionId = useAppStore((s) => getConnectionIdFromState(s, worktreeId)) const { detectedIds } = useDetectedAgents(connectionId) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index d580d2c8c4c..026c5d25a77 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -20,6 +20,8 @@ const useAppStoreMock = vi.fn( activeTabId: string | null activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null gitStatusByWorktree: Record + repos: never[] + worktreesByRepo: Record unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record pinTab: typeof pinTabMock @@ -34,6 +36,8 @@ const useAppStoreMock = vi.fn( activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + repos: [], + worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, pinTab: pinTabMock, @@ -107,6 +111,8 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + repos: [], + worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, pinTab: pinTabMock, diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 2632fffe8bd..94c09c405de 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -52,6 +52,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getConnectionIdFromState } from '@/lib/connection-context' import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, @@ -313,13 +314,11 @@ function TabBarInner({ const activeRuntimeEnvironmentId = useAppStore( (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null ) - const worktreeConnectionId = useAppStore((s) => { - const worktree = Object.values(s.worktreesByRepo ?? {}) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null - return repo?.connectionId?.trim() || null - }) + // Why: retained tab strips rerun selectors on every store write; reuse the + // canonical worktree/repo indexes instead of flattening both slices here. + const worktreeConnectionId = useAppStore( + (s) => getConnectionIdFromState(s, worktreeId)?.trim() || null + ) const worktreeRemotePlatform = useAppStore((s) => { if (!worktreeConnectionId) { return null @@ -331,15 +330,13 @@ function TabBarInner({ (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES ) const agentDetectionTargetKey = useAppStore((s): string | undefined => { - const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat() - const worktree = allWorktrees.find((w) => w.id === worktreeId) - if (!worktree) { + const connectionId = getConnectionIdFromState(s, worktreeId) + if (connectionId === undefined) { return undefined } - const repo = s.repos?.find((r) => r.id === worktree.repoId) - const repoConnectionId = repo?.connectionId?.trim() - if (repoConnectionId) { - return `ssh:${repoConnectionId}` + const normalizedConnectionId = connectionId?.trim() + if (normalizedConnectionId) { + return `ssh:${normalizedConnectionId}` } const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() if (runtimeEnvironmentId) { diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts index 48d27ecf1e4..59e80c48af7 100644 --- a/src/renderer/src/lib/connection-context.test.ts +++ b/src/renderer/src/lib/connection-context.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../shared/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { @@ -521,6 +521,56 @@ describe('getConnectionIdFromState', () => { expect(getConnectionIdFromState(state, 'repo-ssh::/home/neil/repo-feature')).toBe('ssh-2') }) + it('indexes immutable worktree and repo snapshots once across repeated selector calls', () => { + let worktreeIdReads = 0 + let repoIdReads = 0 + const targetWorktreeId = 'worktree-99-99' + const targetRepoId = 'repo-99' + const worktreesByRepo: AppState['worktreesByRepo'] = {} + const repos: Repo[] = [] + + for (let repoIndex = 0; repoIndex < 100; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + const repo = makeRepo({ + id: repoId, + ...(repoId === targetRepoId ? { connectionId: 'ssh-target' } : {}) + }) + Object.defineProperty(repo, 'id', { + enumerable: true, + get: () => { + repoIdReads += 1 + return repoId + } + }) + repos.push(repo) + worktreesByRepo[repoId] = Array.from({ length: 100 }, (_, worktreeIndex) => { + const worktreeId = `worktree-${repoIndex}-${worktreeIndex}` + const worktree = { repoId } as Worktree + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return worktreeId + } + }) + return worktree + }) + } + const state: ConnectionContextState = { + folderWorkspaces: [], + projectGroups: [], + repos, + worktreesByRepo + } + + for (let lookup = 0; lookup < 200; lookup += 1) { + expect(getConnectionIdFromState(state, targetWorktreeId)).toBe('ssh-target') + } + + expect(worktreeIdReads).toBe(10_000) + expect(repoIdReads).toBe(100) + }) + it('returns null for a null worktreeId', () => { const state: ConnectionContextState = { folderWorkspaces: [], diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts index 233ff5a2b4b..260b6149931 100644 --- a/src/renderer/src/lib/connection-context.ts +++ b/src/renderer/src/lib/connection-context.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' @@ -35,12 +36,13 @@ export function getConnectionIdFromState( if (parsedWorkspaceKey?.type === 'folder') { return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) } - const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() - const worktree = allWorktrees.find((w) => w.id === worktreeId) + // Why: retained Zustand selectors call this on unrelated writes; reuse the + // immutable-slice indexes instead of flattening every worktree each time. + const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) // Why: SSH worktrees can be restored from session IDs before relay discovery // repopulates worktreesByRepo. The composite ID still carries the repo ID. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos?.find((r) => r.id === repoId) + const repo = getIndexedRepoMap(state.repos).get(repoId) if (!repo) { return undefined } diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 1ffc7c40db7..76dcb5336af 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -4,6 +4,11 @@ import type { Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' +import { + getIndexedAllWorktrees as getCachedAllWorktrees, + getIndexedRepoMap as getCachedRepoMap, + getIndexedWorktreeMap as getCachedWorktreeMap +} from './worktree-repo-index' export { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' @@ -12,10 +17,6 @@ const EMPTY_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: NonNullable = [] const EMPTY_UNIFIED_TABS: NonNullable = [] -type WorktreeSnapshot = { - allWorktrees: Worktree[] - worktreeMap: Map -} type FloatingVisibleTabCountState = Pick< AppState, 'browserTabsByWorktree' | 'openFiles' | 'tabsByWorktree' | 'unifiedTabsByWorktree' @@ -28,51 +29,9 @@ type FloatingVisibleTabCountCache = { count: number } -// Why: Zustand reruns selectors on every write, so hot-path flatten/map work -// needs cross-render caching. WeakMap ties each snapshot to the store slice ref -// without pinning old test/dev instances in memory once that slice is replaced. -const worktreeSnapshotCache = new WeakMap() const hasAnyWorktreesCache = new WeakMap() -const repoMapCache = new WeakMap>() let floatingVisibleTabCountCache: FloatingVisibleTabCountCache | null = null -function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot { - const cachedSnapshot = worktreeSnapshotCache.get(worktreesByRepo) - if (cachedSnapshot) { - return cachedSnapshot - } - - // Why: a race between createWorktree (which appends) and fetchWorktrees - // (which replaces) can produce duplicate entries for the same worktree ID - // within a single repo's array. Deduplicating here prevents React from - // seeing duplicate keys, which can corrupt terminal DOM containers. - const worktreeMap = new Map() - // Why: this selector sits on hot Zustand subscription paths; avoid building - // a transient flattened array just to populate the snapshot cache. - for (const worktrees of Object.values(worktreesByRepo)) { - for (const worktree of worktrees) { - worktreeMap.set(worktree.id, worktree) - } - } - const allWorktrees = Array.from(worktreeMap.values()) - - const snapshot = { allWorktrees, worktreeMap } - worktreeSnapshotCache.set(worktreesByRepo, snapshot) - return snapshot -} - -function getCachedAllWorktrees(worktreesByRepo: AppState['worktreesByRepo']): Worktree[] { - return getWorktreeSnapshot(worktreesByRepo).allWorktrees -} - -function getCachedWorktreeMap(worktreesByRepo: AppState['worktreesByRepo']): Map { - const snapshot = worktreeSnapshotCache.get(worktreesByRepo) - if (snapshot) { - return snapshot.worktreeMap - } - return getWorktreeSnapshot(worktreesByRepo).worktreeMap -} - function getCachedHasAnyWorktrees(worktreesByRepo: AppState['worktreesByRepo']): boolean { const cached = hasAnyWorktreesCache.get(worktreesByRepo) if (cached !== undefined) { @@ -86,17 +45,6 @@ function getCachedHasAnyWorktrees(worktreesByRepo: AppState['worktreesByRepo']): return hasWorktrees } -function getCachedRepoMap(repos: AppState['repos']): Map { - const cachedMap = repoMapCache.get(repos) - if (cachedMap) { - return cachedMap - } - - const repoMap = new Map(repos.map((repo) => [repo.id, repo])) - repoMapCache.set(repos, repoMap) - return repoMap -} - export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountState): number { const terminalTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS const browserTabs = diff --git a/src/renderer/src/store/worktree-diff-comments-selector.test.ts b/src/renderer/src/store/worktree-diff-comments-selector.test.ts new file mode 100644 index 00000000000..0f6ceb5d99e --- /dev/null +++ b/src/renderer/src/store/worktree-diff-comments-selector.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import type { DiffComment, Worktree } from '../../../shared/types' +import type { AppState } from './types' +import { + selectWorktreeDiffComments, + selectWorktreeDiffCommentsOrEmpty +} from './worktree-diff-comments-selector' +import { getIndexedWorktreeById } from './worktree-repo-index' + +function makeComment(id: string): DiffComment { + return { + id, + worktreeId: 'worktree-99-99', + filePath: 'src/index.ts', + lineNumber: 1, + body: 'Review note', + createdAt: 1, + updatedAt: 1, + source: 'diff', + side: 'modified' + } +} + +describe('selectWorktreeDiffComments', () => { + it('indexes one immutable worktree snapshot across retained editor selectors', () => { + let worktreeIdReads = 0 + const targetWorktreeId = 'worktree-99-99' + const comments = [makeComment('comment-1')] + const worktreesByRepo: AppState['worktreesByRepo'] = {} + + for (let repoIndex = 0; repoIndex < 100; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + worktreesByRepo[repoId] = Array.from({ length: 100 }, (_, worktreeIndex) => { + const worktreeId = `worktree-${repoIndex}-${worktreeIndex}` + const worktree = { + repoId, + path: `/${repoId}/${worktreeId}`, + ...(worktreeId === targetWorktreeId ? { diffComments: comments } : {}) + } as Worktree + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return worktreeId + } + }) + return worktree + }) + } + + for (let write = 0; write < 200; write += 1) { + // Model all seven comment selectors plus the rich-editor path selector. + expect(selectWorktreeDiffComments({ worktreesByRepo }, targetWorktreeId)).toBe(comments) + expect(selectWorktreeDiffComments({ worktreesByRepo }, targetWorktreeId)).toBe(comments) + expect(selectWorktreeDiffComments({ worktreesByRepo }, targetWorktreeId)).toBe(comments) + expect(selectWorktreeDiffComments({ worktreesByRepo }, targetWorktreeId)).toBe(comments) + expect(selectWorktreeDiffCommentsOrEmpty({ worktreesByRepo }, targetWorktreeId)).toBe( + comments + ) + expect(selectWorktreeDiffCommentsOrEmpty({ worktreesByRepo }, targetWorktreeId)).toBe( + comments + ) + expect(selectWorktreeDiffCommentsOrEmpty({ worktreesByRepo }, targetWorktreeId)).toBe( + comments + ) + expect(getIndexedWorktreeById(worktreesByRepo, targetWorktreeId)?.path).toBe( + '/repo-99/worktree-99-99' + ) + } + + expect(worktreeIdReads).toBe(10_000) + }) + + it('reads a replacement worktree snapshot and handles an absent id', () => { + const firstComments = [makeComment('comment-1')] + const nextComments = [makeComment('comment-2')] + const first = { repo: [{ id: 'worktree-1', diffComments: firstComments } as Worktree] } + const next = { repo: [{ id: 'worktree-1', diffComments: nextComments } as Worktree] } + + expect(selectWorktreeDiffComments({ worktreesByRepo: first }, 'worktree-1')).toBe(firstComments) + expect(selectWorktreeDiffComments({ worktreesByRepo: next }, 'worktree-1')).toBe(nextComments) + expect(selectWorktreeDiffComments({ worktreesByRepo: next }, null)).toBeUndefined() + expect(selectWorktreeDiffCommentsOrEmpty({ worktreesByRepo: next }, null)).toBe( + selectWorktreeDiffCommentsOrEmpty({ worktreesByRepo: next }, undefined) + ) + }) +}) diff --git a/src/renderer/src/store/worktree-diff-comments-selector.ts b/src/renderer/src/store/worktree-diff-comments-selector.ts new file mode 100644 index 00000000000..c91ef64bc06 --- /dev/null +++ b/src/renderer/src/store/worktree-diff-comments-selector.ts @@ -0,0 +1,24 @@ +import type { DiffComment } from '../../../shared/types' +import type { AppState } from './types' +import { getIndexedWorktreeById } from './worktree-repo-index' + +const EMPTY_DIFF_COMMENTS = Object.freeze([]) as unknown as DiffComment[] + +export function selectWorktreeDiffComments( + state: Pick, + worktreeId: string | null | undefined +): DiffComment[] | undefined { + if (!worktreeId) { + return undefined + } + // Why: mounted Monaco and diff surfaces rerun this selector on every store + // write, so share the immutable-snapshot index instead of rescanning all worktrees. + return getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.diffComments +} + +export function selectWorktreeDiffCommentsOrEmpty( + state: Pick, + worktreeId: string | null | undefined +): DiffComment[] { + return selectWorktreeDiffComments(state, worktreeId) ?? EMPTY_DIFF_COMMENTS +} diff --git a/src/renderer/src/store/worktree-repo-index.ts b/src/renderer/src/store/worktree-repo-index.ts new file mode 100644 index 00000000000..9c459f90f86 --- /dev/null +++ b/src/renderer/src/store/worktree-repo-index.ts @@ -0,0 +1,61 @@ +import type { Repo, Worktree } from '../../../shared/types' +import type { AppState } from './types' + +type WorktreeSnapshot = { + allWorktrees: Worktree[] + worktreeMap: Map +} + +// Why: Zustand reruns selectors on every write, so identity projections need +// cross-render caching without pinning replaced store snapshots in memory. +const worktreeSnapshotCache = new WeakMap() +const repoMapCache = new WeakMap>() + +function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot { + const cachedSnapshot = worktreeSnapshotCache.get(worktreesByRepo) + if (cachedSnapshot) { + return cachedSnapshot + } + + // Why: a race between createWorktree (which appends) and fetchWorktrees + // (which replaces) can produce duplicate entries within one repo array. + const worktreeMap = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + worktreeMap.set(worktree.id, worktree) + } + } + const snapshot = { + allWorktrees: Array.from(worktreeMap.values()), + worktreeMap + } + worktreeSnapshotCache.set(worktreesByRepo, snapshot) + return snapshot +} + +export function getIndexedAllWorktrees(worktreesByRepo: AppState['worktreesByRepo']): Worktree[] { + return getWorktreeSnapshot(worktreesByRepo).allWorktrees +} + +export function getIndexedWorktreeMap( + worktreesByRepo: AppState['worktreesByRepo'] +): Map { + return getWorktreeSnapshot(worktreesByRepo).worktreeMap +} + +export function getIndexedWorktreeById( + worktreesByRepo: AppState['worktreesByRepo'], + worktreeId: string +): Worktree | undefined { + return getWorktreeSnapshot(worktreesByRepo).worktreeMap.get(worktreeId) +} + +export function getIndexedRepoMap(repos: AppState['repos']): Map { + const cachedMap = repoMapCache.get(repos) + if (cachedMap) { + return cachedMap + } + const repoMap = new Map(repos.map((repo) => [repo.id, repo])) + repoMapCache.set(repos, repoMap) + return repoMap +} From 8b1054de19ca13c7c146ebe5b719684fc6dca4b6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:49 -0700 Subject: [PATCH 010/138] perf(renderer): index runtime owner lookups (#8089) --- .../src/lib/worktree-runtime-owner-index.ts | 94 ++++++++++++++ .../src/lib/worktree-runtime-owner.test.ts | 120 ++++++++++++++++++ .../src/lib/worktree-runtime-owner.ts | 29 ++--- 3 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/lib/worktree-runtime-owner-index.ts diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts new file mode 100644 index 00000000000..c7b950ffbbe --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -0,0 +1,94 @@ +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../shared/types' + +type WorktreeOwnerRecord = Pick +type RepoOwnerRecord = Pick +type FolderWorkspaceOwnerRecord = Pick +type ProjectGroupOwnerRecord = Pick + +// Why: owner resolution runs inside retained selectors and interaction paths; +// immutable-slice indexes prevent unrelated store writes from rescanning. +const worktreeOwnerIndexCache = new WeakMap< + Record, + ReadonlyMap +>() +const repoOwnerIndexCache = new WeakMap< + readonly RepoOwnerRecord[], + ReadonlyMap +>() +const folderWorkspaceOwnerIndexCache = new WeakMap< + readonly FolderWorkspaceOwnerRecord[], + ReadonlyMap +>() +const projectGroupOwnerIndexCache = new WeakMap< + readonly ProjectGroupOwnerRecord[], + ReadonlyMap +>() + +function findIndexedOwnerRecord( + records: readonly T[] | undefined, + id: string, + cache: WeakMap> +): T | null { + if (!records) { + return null + } + let index = cache.get(records) + if (!index) { + const next = new Map() + for (const record of records) { + const recordId = record.id + if (!next.has(recordId)) { + // Preserve the prior Array.find behavior for invalid duplicate IDs. + next.set(recordId, record) + } + } + index = next + cache.set(records, index) + } + return index.get(id) ?? null +} + +export function findIndexedWorktreeOwner( + worktreesByRepo: Record | undefined, + worktreeId: string +): WorktreeOwnerRecord | null { + if (!worktreesByRepo) { + return null + } + let index = worktreeOwnerIndexCache.get(worktreesByRepo) + if (!index) { + const next = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + const id = worktree.id + if (!next.has(id)) { + next.set(id, worktree) + } + } + } + index = next + worktreeOwnerIndexCache.set(worktreesByRepo, index) + } + return index.get(worktreeId) ?? null +} + +export function findIndexedRepoOwner( + repos: readonly RepoOwnerRecord[] | undefined, + repoId: string +): RepoOwnerRecord | null { + return findIndexedOwnerRecord(repos, repoId, repoOwnerIndexCache) +} + +export function findIndexedFolderWorkspaceOwner( + folderWorkspaces: readonly FolderWorkspaceOwnerRecord[] | undefined, + folderWorkspaceId: string +): FolderWorkspaceOwnerRecord | null { + return findIndexedOwnerRecord(folderWorkspaces, folderWorkspaceId, folderWorkspaceOwnerIndexCache) +} + +export function findIndexedProjectGroupOwner( + projectGroups: readonly ProjectGroupOwnerRecord[] | undefined, + projectGroupId: string +): ProjectGroupOwnerRecord | null { + return findIndexedOwnerRecord(projectGroups, projectGroupId, projectGroupOwnerIndexCache) +} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 823705d1033..129f7e59e06 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -198,6 +198,126 @@ describe('getExplicitRuntimeEnvironmentIdForWorktree', () => { }) }) +describe('runtime owner identity indexes', () => { + it('indexes immutable owner records once across repeated worktree and folder lookups', () => { + let worktreeIdReads = 0 + let repoIdReads = 0 + let folderWorkspaceIdReads = 0 + let projectGroupIdReads = 0 + const targetWorktreeId = 'worktree-99-99' + const targetRepoId = 'repo-99' + const targetFolderWorkspaceId = 'folder-workspace-999' + const targetProjectGroupId = 'project-group-999' + const repos: NonNullable[number][] = [] + const worktreesByRepo: NonNullable = {} + const folderWorkspaces: NonNullable[number][] = + [] + const projectGroups: NonNullable[number][] = [] + + for (let repoIndex = 0; repoIndex < 100; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + const repo: NonNullable[number] = { + id: repoId, + connectionId: null, + executionHostId: repoId === targetRepoId ? 'runtime:repo-owner' : 'local' + } + Object.defineProperty(repo, 'id', { + enumerable: true, + get: () => { + repoIdReads += 1 + return repoId + } + }) + repos.push(repo) + worktreesByRepo[repoId] = Array.from({ length: 100 }, (_, worktreeIndex) => { + const worktreeId = `worktree-${repoIndex}-${worktreeIndex}` + const worktree = { id: worktreeId, repoId } + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + worktreeIdReads += 1 + return worktreeId + } + }) + return worktree + }) + } + for (let index = 0; index < 1_000; index += 1) { + const folderWorkspaceId = `folder-workspace-${index}` + const projectGroupId = `project-group-${index}` + const folderWorkspace = { id: folderWorkspaceId, projectGroupId } + const projectGroup: NonNullable[number] = { + id: projectGroupId, + connectionId: null, + executionHostId: projectGroupId === targetProjectGroupId ? 'runtime:folder-owner' : 'local' + } + Object.defineProperty(folderWorkspace, 'id', { + enumerable: true, + get: () => { + folderWorkspaceIdReads += 1 + return folderWorkspaceId + } + }) + Object.defineProperty(projectGroup, 'id', { + enumerable: true, + get: () => { + projectGroupIdReads += 1 + return projectGroupId + } + }) + folderWorkspaces.push(folderWorkspace) + projectGroups.push(projectGroup) + } + const indexedState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos, + worktreesByRepo, + folderWorkspaces, + projectGroups + } + + for (let lookup = 0; lookup < 200; lookup += 1) { + getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId) + getExplicitRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId) + getExecutionHostIdForWorktree(indexedState, targetWorktreeId) + getRuntimeEnvironmentIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`) + getExplicitRuntimeEnvironmentIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`) + getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`) + } + + expect(worktreeIdReads).toBe(10_000) + expect(repoIdReads).toBe(100) + expect(folderWorkspaceIdReads).toBe(1_000) + expect(projectGroupIdReads).toBe(1_000) + expect(getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId)).toBe('repo-owner') + expect(getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`)).toBe( + 'runtime:folder-owner' + ) + }) + + it('rebuilds owner indexes when immutable collection references change', () => { + const first: WorktreeRuntimeOwnerState = { + repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:repo-one' }], + worktreesByRepo: { repo: [{ id: 'worktree', repoId: 'repo' }] }, + folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }], + projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-one' }] + } + expect(getRuntimeEnvironmentIdForWorktree(first, 'worktree')).toBe('repo-one') + expect(getRuntimeEnvironmentIdForWorktree(first, 'folder:folder')).toBe('folder-one') + + const second: WorktreeRuntimeOwnerState = { + repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:repo-two' }], + worktreesByRepo: { + repo: [{ id: 'worktree', repoId: 'repo', hostId: 'runtime:worktree-two' }] + }, + folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }], + projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-two' }] + } + expect(getRuntimeEnvironmentIdForWorktree(second, 'worktree')).toBe('worktree-two') + expect(getRuntimeEnvironmentIdForWorktree(second, 'folder:folder')).toBe('folder-two') + }) +}) + describe('getRuntimeSessionMirrorEnvironmentIds', () => { it('includes focused runtime plus explicit repo, worktree, and folder owners', () => { const multiRuntimeState: WorktreeRuntimeOwnerState = { diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 71acd81a280..a7616b4ddf9 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -14,6 +14,12 @@ import type { import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { + findIndexedFolderWorkspaceOwner, + findIndexedProjectGroupOwner, + findIndexedRepoOwner as findRepoRecord, + findIndexedWorktreeOwner as findWorktreeRecord +} from './worktree-runtime-owner-index' type RuntimeExecutionHost = Extract @@ -26,19 +32,6 @@ export type WorktreeRuntimeOwnerState = { restoredRuntimeHostIdByWorkspaceSessionKey?: Record } -function findWorktreeRecord( - worktreesByRepo: WorktreeRuntimeOwnerState['worktreesByRepo'], - worktreeId: string -): Pick | null { - for (const worktrees of Object.values(worktreesByRepo ?? {})) { - const match = worktrees.find((worktree) => worktree.id === worktreeId) - if (match) { - return match - } - } - return null -} - function findFolderProjectGroup( state: WorktreeRuntimeOwnerState, folderWorkspaceId: string @@ -47,14 +40,14 @@ function findFolderProjectGroup( if (!folderWorkspace) { return null } - return state.projectGroups?.find((group) => group.id === folderWorkspace.projectGroupId) ?? null + return findIndexedProjectGroupOwner(state.projectGroups, folderWorkspace.projectGroupId) } function findFolderWorkspace( state: WorktreeRuntimeOwnerState, folderWorkspaceId: string ): Pick | null { - return state.folderWorkspaces?.find((workspace) => workspace.id === folderWorkspaceId) ?? null + return findIndexedFolderWorkspaceOwner(state.folderWorkspaces, folderWorkspaceId) } function getRuntimeEnvironmentIdForFolderWorkspace( @@ -177,7 +170,7 @@ export function getRuntimeEnvironmentIdForWorktree( return worktreeRuntimeEnvironmentId } const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoRecord(state.repos, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) @@ -205,7 +198,7 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( return getExplicitRuntimeEnvironmentIdFromHost(worktree.hostId) } const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoRecord(state.repos, repoId) if (!repo) { return null } @@ -271,7 +264,7 @@ export function getExecutionHostIdForWorktree( return worktreeHostId } const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoRecord(state.repos, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { return getRepoExecutionHostId(repo) From d617bda234997756644329a59ebe0b790a79939b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:51 -0700 Subject: [PATCH 011/138] perf(renderer): scope active review cache subscriptions (#8097) --- .../components/right-sidebar/ChecksPanel.tsx | 6 +- .../right-sidebar/SourceControl.tsx | 69 ++++++++-------- .../review-cache-entry-selection.test.ts | 79 +++++++++++++++++++ .../review-cache-entry-selection.ts | 17 ++++ 4 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts create mode 100644 src/renderer/src/components/right-sidebar/review-cache-entry-selection.ts diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 0682c1b0790..c6c23f95b21 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -87,6 +87,7 @@ import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { type ChecksPanelReview, selectChecksPanelReview } from './checks-panel-review' +import { selectReviewCacheEntry } from './review-cache-entry-selection' import { checksPanelAsyncResultKey, checksPanelHostedReviewAsyncResultKey, @@ -394,7 +395,6 @@ export default function ChecksPanel(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const updateRepo = useAppStore((s) => s.updateRepo) - const prCache = useAppStore((s) => s.prCache) const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) const expireGitHubPRRefreshState = useAppStore((s) => s.expireGitHubPRRefreshState) @@ -648,7 +648,9 @@ export default function ChecksPanel(): React.JSX.Element { refreshContextKeyRef.current = refreshContextKey refreshRequestKeyRef.current = null } - const prCacheEntry = prCacheKey ? prCache[prCacheKey] : undefined + // Why: background PR refreshes replace the cache map; Checks only renders + // the entry for the active repo and branch. + const prCacheEntry = useAppStore((s) => selectReviewCacheEntry(s.prCache, prCacheKey || null)) const pr: PRInfo | null = prCacheEntry?.data ?? null const hostedReview = useAppStore((s) => hostedReviewCacheKey ? (s.hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index eee38d1eb75..22e3878848b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -111,6 +111,7 @@ import { type SourceControlSectionArea } from './source-control-section-order' import { SourceControlVirtualFileList } from './source-control-virtual-file-list' +import { selectReviewCacheData, selectReviewCacheEntry } from './review-cache-entry-selection' import { buildActiveOpenFileSignature, buildActiveOpenRowKeys @@ -820,6 +821,9 @@ function SourceControlInner(): React.JSX.Element { const worktreeMap = useWorktreeMap() const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null + const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null + const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' const entries = useAppStore((s) => activeWorktreeId ? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES) @@ -851,6 +855,37 @@ function SourceControlInner(): React.JSX.Element { const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) const settings = useAppStore((s) => s.settings) + const hostedReviewCacheKey = + activeRepo && branchName + ? getHostedReviewCacheKey( + activeRepo.path, + branchName, + settings, + activeRepo.id, + activeRepo.connectionId, + activeRepo.executionHostId, + true + ) + : null + const activePrCacheKey = + activeRepo && branchName + ? getGitHubPRCacheKey( + activeRepo.path, + activeRepo.id, + branchName, + settings, + activeRepo.connectionId, + activeRepo.executionHostId, + true + ) + : null + // Why: background worktree review refreshes replace both cache maps; this + // large panel only needs the entries for its active repo and branch. + const hostedReviewEntry = useAppStore((s) => + selectReviewCacheEntry(s.hostedReviewCache, hostedReviewCacheKey) + ) + const hostedReviewEntryData = hostedReviewEntry?.data ?? null + const activePrFromQueue = useAppStore((s) => selectReviewCacheData(s.prCache, activePrCacheKey)) // Why: git/file mutations and repo metadata requests belong to the repo // OWNER host, not the currently focused host in the sidebar. const activeRepoSettings = useMemo( @@ -860,7 +895,6 @@ function SourceControlInner(): React.JSX.Element { const updateSettings = useAppStore((s) => s.updateSettings) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const hostedReviewCache = useAppStore((s) => s.hostedReviewCache) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) const getHostedReviewCreationEligibility = useAppStore( (s) => s.getHostedReviewCreationEligibility @@ -868,7 +902,6 @@ function SourceControlInner(): React.JSX.Element { const createHostedReview = useAppStore((s) => s.createHostedReview) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) - const prCache = useAppStore((s) => s.prCache) const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) const updateRepo = useAppStore((s) => s.updateRepo) const setGitStatus = useAppStore((s) => s.setGitStatus) @@ -1214,9 +1247,6 @@ function SourceControlInner(): React.JSX.Element { ? undefined : getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId) }) - const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null - const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null - const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath, @@ -1421,35 +1451,6 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation?.provider ) const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider) - const hostedReviewCacheKey = - activeRepo && branchName - ? getHostedReviewCacheKey( - activeRepo.path, - branchName, - settings, - activeRepo.id, - activeRepo.connectionId, - activeRepo.executionHostId, - true - ) - : null - const hostedReviewEntry = hostedReviewCacheKey - ? hostedReviewCache[hostedReviewCacheKey] - : undefined - const activePrCacheKey = - activeRepo && branchName - ? getGitHubPRCacheKey( - activeRepo.path, - activeRepo.id, - branchName, - settings, - activeRepo.connectionId, - activeRepo.executionHostId, - true - ) - : null - const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null - const hostedReviewEntryData = hostedReviewEntry?.data ?? null const hostedReview: HostedReviewInfo | null = useMemo(() => { if (!hostedReviewCacheKey) { return null diff --git a/src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts b/src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts new file mode 100644 index 00000000000..251dce59a3f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { selectReviewCacheData, selectReviewCacheEntry } from './review-cache-entry-selection' + +type Review = { id: string } +type ReviewEntry = { data: Review | null; fetchedAt: number } + +const ACTIVE_KEY = 'repo-active::feature' + +function replaceUnrelatedEntry( + cache: Record, + index: number +): Record { + return { + ...cache, + [`repo-background-${index % 100}::feature`]: { + data: { id: `background-${index}` }, + fetchedAt: index + } + } +} + +describe('review cache entry selection', () => { + it('turns unrelated active-panel cache invalidations into stable selections', () => { + const activeHostedReview = { id: 'hosted-active' } + const activePullRequest = { id: 'pr-active' } + let hostedCache: Record = { + [ACTIVE_KEY]: { data: activeHostedReview, fetchedAt: 1 } + } + let pullRequestCache: Record = { + [ACTIVE_KEY]: { data: activePullRequest, fetchedAt: 1 } + } + let previousHostedMap = hostedCache + let previousPullRequestMap = pullRequestCache + let previousHostedSelection = selectReviewCacheData(hostedCache, ACTIVE_KEY) + let previousPullRequestSelection = selectReviewCacheEntry(pullRequestCache, ACTIVE_KEY) + let wholeMapInvalidations = 0 + let scopedSelectionInvalidations = 0 + + for (let index = 0; index < 200; index += 1) { + hostedCache = replaceUnrelatedEntry(hostedCache, index) + pullRequestCache = replaceUnrelatedEntry(pullRequestCache, index) + if (hostedCache !== previousHostedMap) { + wholeMapInvalidations += 1 + } + if (pullRequestCache !== previousPullRequestMap) { + wholeMapInvalidations += 1 + } + + const hostedSelection = selectReviewCacheData(hostedCache, ACTIVE_KEY) + const pullRequestSelection = selectReviewCacheEntry(pullRequestCache, ACTIVE_KEY) + if (hostedSelection !== previousHostedSelection) { + scopedSelectionInvalidations += 1 + } + if (pullRequestSelection !== previousPullRequestSelection) { + scopedSelectionInvalidations += 1 + } + + previousHostedMap = hostedCache + previousPullRequestMap = pullRequestCache + previousHostedSelection = hostedSelection + previousPullRequestSelection = pullRequestSelection + } + + expect(wholeMapInvalidations).toBe(400) + expect(scopedSelectionInvalidations).toBe(0) + }) + + it('publishes relevant entry replacements and handles absent keys', () => { + const first = { data: { id: 'first' }, fetchedAt: 1 } + const second = { data: { id: 'second' }, fetchedAt: 2 } + + expect(selectReviewCacheEntry({ [ACTIVE_KEY]: first }, ACTIVE_KEY)).toBe(first) + expect(selectReviewCacheEntry({ [ACTIVE_KEY]: second }, ACTIVE_KEY)).toBe(second) + expect(selectReviewCacheData({ [ACTIVE_KEY]: second }, ACTIVE_KEY)).toBe(second.data) + expect(selectReviewCacheEntry({}, ACTIVE_KEY)).toBeUndefined() + expect(selectReviewCacheData({}, ACTIVE_KEY)).toBeNull() + expect(selectReviewCacheEntry({ [ACTIVE_KEY]: first }, null)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/review-cache-entry-selection.ts b/src/renderer/src/components/right-sidebar/review-cache-entry-selection.ts new file mode 100644 index 00000000000..744cdddea8d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/review-cache-entry-selection.ts @@ -0,0 +1,17 @@ +type ReviewCacheEntry = { + data: T | null +} + +export function selectReviewCacheEntry>( + cache: Readonly>, + key: string | null +): Entry | undefined { + return key ? cache[key] : undefined +} + +export function selectReviewCacheData( + cache: Readonly>>, + key: string | null +): T | null { + return selectReviewCacheEntry(cache, key)?.data ?? null +} From e0207cb36012cbea37df62756cc4f40b8cb4f5d7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:54 -0700 Subject: [PATCH 012/138] perf(renderer): scope editor draft subscriptions by panel (#8105) * perf(renderer): scope editor draft subscriptions by panel * fix(renderer): keep conflict overview drafts live --- .../src/components/editor/EditorPanel.tsx | 9 +- .../editor-panel-draft-selector.test.ts | 109 ++++++++++++++++++ .../editor/editor-panel-draft-selector.ts | 63 ++++++++++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-panel-draft-selector.test.ts create mode 100644 src/renderer/src/components/editor/editor-panel-draft-selector.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index dd89fd7ab39..2181dfc13eb 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' @@ -22,6 +22,7 @@ import { selectEditorPanelGitBranchEntries, selectEditorPanelGitStatusEntries } from './editor-panel-git-entry-selector' +import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' function EditorPanelInner({ activeFileId: activeFileIdProp, @@ -60,7 +61,11 @@ function EditorPanelInner({ const setMarkdownTableOfContentsVisible = useAppStore((s) => s.setMarkdownTableOfContentsVisible) const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) - const editorDrafts = useAppStore((s) => s.editorDrafts) + const editorDraftSelector = useMemo( + () => createEditorPanelDraftSelector(activeFile), + [activeFile] + ) + const editorDrafts = useAppStore(editorDraftSelector) const setEditorDraft = useAppStore((s) => s.setEditorDraft) const settings = useAppStore((s) => s.settings) const panelRef = useRef(null) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts new file mode 100644 index 00000000000..d8b4747a9b5 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' + +function makeFile(id: string, overrides: Partial = {}): OpenFile { + return { + id, + filePath: `/repo/${id}.ts`, + relativePath: `${id}.ts`, + worktreeId: 'worktree-1', + language: 'typescript', + mode: 'edit', + isDirty: false, + ...overrides + } +} + +describe('createEditorPanelDraftSelector', () => { + it('limits draft invalidations to the panel that owns each keystroke', () => { + const files = Array.from({ length: 200 }, (_, index) => makeFile(`file-${index}`)) + let editorDrafts: Record = {} + let wholeMapInvalidations = 0 + let scopedInvalidations = 0 + let unrelatedPanelInvalidations = 0 + const selectors = files.map(createEditorPanelDraftSelector) + const previousSelections = selectors.map((selector) => selector({ editorDrafts })) + + for (let edit = 0; edit < 200; edit += 1) { + const previousDrafts = editorDrafts + editorDrafts = { ...editorDrafts, 'file-0': `edit-${edit}` } + for (let panelIndex = 0; panelIndex < files.length; panelIndex += 1) { + if (previousDrafts !== editorDrafts) { + wholeMapInvalidations += 1 + } + const nextSelection = selectors[panelIndex]({ editorDrafts }) + if (previousSelections[panelIndex] !== nextSelection) { + scopedInvalidations += 1 + if (panelIndex !== 0) { + unrelatedPanelInvalidations += 1 + } + previousSelections[panelIndex] = nextSelection + } + } + } + + expect(wholeMapInvalidations).toBe(40_000) + expect(scopedInvalidations).toBe(200) + expect(unrelatedPanelInvalidations).toBe(0) + }) + + it('includes preview and selected conflict-review drafts but excludes unrelated files', () => { + const preview = makeFile('preview', { markdownPreviewSourceFileId: 'source' }) + const conflictReview = makeFile('review', { + mode: 'conflict-review', + conflictReview: { selectedFileId: 'selected' } as NonNullable + }) + const editorDrafts = { + preview: 'preview draft', + source: '', + review: 'review draft', + selected: 'selected draft', + unrelated: 'other draft' + } + const selectPreviewDrafts = createEditorPanelDraftSelector(preview) + const selectConflictDrafts = createEditorPanelDraftSelector(conflictReview) + const selectNoDrafts = createEditorPanelDraftSelector(null) + + expect(selectPreviewDrafts({ editorDrafts })).toEqual({ + preview: 'preview draft', + source: '' + }) + expect(selectConflictDrafts({ editorDrafts })).toEqual({ + review: 'review draft', + selected: 'selected draft' + }) + expect(selectNoDrafts({ editorDrafts })).toEqual({}) + }) + + it('includes overview conflict drafts and ignores unrelated draft replacements', () => { + const conflictReview = makeFile('review', { + filePath: 'C:\\repo', + mode: 'conflict-review', + conflictReview: { + source: 'live-summary', + snapshotTimestamp: 1, + entries: [ + { path: 'src/a.ts', conflictKind: 'both_modified' }, + { path: 'src\\b.ts', conflictKind: 'both_modified' } + ] + } + }) + const selectDrafts = createEditorPanelDraftSelector(conflictReview) + const editorDrafts = { + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '', + unrelated: 'other draft' + } + + const selection = selectDrafts({ editorDrafts }) + expect(selection).toEqual({ + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '' + }) + + expect( + selectDrafts({ editorDrafts: { ...editorDrafts, unrelated: 'changed elsewhere' } }) + ).toBe(selection) + }) +}) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.ts new file mode 100644 index 00000000000..8cc7e07fb27 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.ts @@ -0,0 +1,63 @@ +import type { AppState } from '@/store' +import { joinPath } from '@/lib/path' +import type { OpenFile } from '@/store/slices/editor' + +type EditorDraftState = Pick +type EditorPanelDraftSelector = (state: EditorDraftState) => Record + +const EMPTY_EDITOR_PANEL_DRAFTS = Object.freeze({}) as Record + +export function createEditorPanelDraftSelector( + activeFile: OpenFile | null +): EditorPanelDraftSelector { + // Why: previews and conflict review can render a related file, but drafts + // from every other panel must not wake this editor on each keystroke. + const fileIds = activeFile + ? Array.from( + new Set( + [ + activeFile.id, + activeFile.markdownPreviewSourceFileId, + activeFile.conflictReview?.selectedFileId, + ...(activeFile.mode === 'conflict-review' && !activeFile.conflictReview?.selectedFileId + ? (activeFile.conflictReview?.entries ?? []).map((entry) => + joinPath(activeFile.filePath, entry.path) + ) + : []) + ].filter((fileId): fileId is string => Boolean(fileId)) + ) + ) + : [] + let previousDrafts: AppState['editorDrafts'] | null = null + let previousSelection = EMPTY_EDITOR_PANEL_DRAFTS + + return (state) => { + // Why: every Zustand write reruns the selector. The slice identity guard + // keeps unrelated terminal/status traffic allocation-free. + if (previousDrafts === state.editorDrafts) { + return previousSelection + } + previousDrafts = state.editorDrafts + + const changed = fileIds.some((fileId) => { + const draft = state.editorDrafts[fileId] + return ( + draft !== previousSelection[fileId] || + (draft === undefined && Object.prototype.hasOwnProperty.call(previousSelection, fileId)) + ) + }) + if (!changed) { + return previousSelection + } + + const nextSelection: Record = {} + for (const fileId of fileIds) { + const draft = state.editorDrafts[fileId] + if (draft !== undefined) { + nextSelection[fileId] = draft + } + } + previousSelection = nextSelection + return previousSelection + } +} From 3afea9e9f210589ac530d2d69202b454a84ff39e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:56 -0700 Subject: [PATCH 013/138] perf(renderer): scope Markdown worktree path subscriptions (#8111) --- ...wn-document-worktree-path-selector.test.ts | 106 ++++++++++++++++++ ...arkdown-document-worktree-path-selector.ts | 12 ++ .../components/editor/useMarkdownDocuments.ts | 13 +-- 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts create mode 100644 src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts diff --git a/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts new file mode 100644 index 00000000000..b8b19f085e8 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' + +const PANEL_COUNT = 200 +const UPDATE_COUNT = 200 +const WORKTREE_COUNT = 100 +const ACTIVE_ID = `worktree-${WORKTREE_COUNT - 1}` + +type CountedFixture = { + readCount: () => number + replaceBackgroundWorktree: (worktrees: Worktree[], update: number) => Worktree[] + resetReadCount: () => void + worktrees: Worktree[] +} + +function createCountedFixture(): CountedFixture { + let reads = 0 + const makeWorktree = (index: number): Worktree => { + const worktree = { + path: `/worktrees/${index}` + } as Worktree + Object.defineProperty(worktree, 'id', { + enumerable: true, + get: () => { + reads += 1 + return `worktree-${index}` + } + }) + return worktree + } + return { + readCount: () => reads, + replaceBackgroundWorktree: (worktrees, update) => { + const next = [...worktrees] + const index = update % (WORKTREE_COUNT - 1) + next[index] = makeWorktree(index) + return next + }, + resetReadCount: () => { + reads = 0 + }, + worktrees: Array.from({ length: WORKTREE_COUNT }, (_, index) => makeWorktree(index)) + } +} + +describe('Markdown document worktree path selector', () => { + it('keeps mounted editors stable across worktree metadata replacements', () => { + const wholeMapFixture = createCountedFixture() + let wholeMap = { repo: wholeMapFixture.worktrees } + let wholeMapInvalidations = 0 + wholeMapFixture.resetReadCount() + + for (let update = 0; update < UPDATE_COUNT; update += 1) { + const previousMap = wholeMap + wholeMap = { + repo: wholeMapFixture.replaceBackgroundWorktree(wholeMap.repo, update) + } + for (let panel = 0; panel < PANEL_COUNT; panel += 1) { + wholeMapInvalidations += Number(previousMap !== wholeMap) + expect(findWorktreeById(wholeMap, ACTIVE_ID)?.path).toBe(`/worktrees/${WORKTREE_COUNT - 1}`) + } + } + + const scopedFixture = createCountedFixture() + let scopedMap = { repo: scopedFixture.worktrees } + const selectedPaths = Array.from({ length: PANEL_COUNT }, () => + selectMarkdownDocumentWorktreePath({ worktreesByRepo: scopedMap }, ACTIVE_ID) + ) + let scopedInvalidations = 0 + scopedFixture.resetReadCount() + + for (let update = 0; update < UPDATE_COUNT; update += 1) { + scopedMap = { + repo: scopedFixture.replaceBackgroundWorktree(scopedMap.repo, update) + } + for (let panel = 0; panel < PANEL_COUNT; panel += 1) { + const nextPath = selectMarkdownDocumentWorktreePath( + { worktreesByRepo: scopedMap }, + ACTIVE_ID + ) + scopedInvalidations += Number(selectedPaths[panel] !== nextPath) + selectedPaths[panel] = nextPath + } + } + + expect(wholeMapInvalidations).toBe(40_000) + expect(scopedInvalidations).toBe(0) + expect(wholeMapFixture.readCount()).toBe(4_000_000) + expect(scopedFixture.readCount()).toBe(20_000) + }) + + it('publishes path replacements and handles an absent worktree', () => { + const first = { id: 'active', path: '/worktrees/first' } as Worktree + const next = { id: 'active', path: '/worktrees/next' } as Worktree + + expect( + selectMarkdownDocumentWorktreePath({ worktreesByRepo: { repo: [first] } }, 'active') + ).toBe('/worktrees/first') + expect( + selectMarkdownDocumentWorktreePath({ worktreesByRepo: { repo: [next] } }, 'active') + ).toBe('/worktrees/next') + expect(selectMarkdownDocumentWorktreePath({ worktreesByRepo: {} }, null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts new file mode 100644 index 00000000000..2c342acff1f --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts @@ -0,0 +1,12 @@ +import { getWorktreeMapFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' + +export function selectMarkdownDocumentWorktreePath( + state: Pick, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + return getWorktreeMapFromState(state).get(worktreeId)?.path ?? null +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 080fdcaca95..1dd3c22c6fd 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' -import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getConnectionId } from '@/lib/connection-context' import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -11,6 +10,7 @@ import { getMarkdownDocLinkAnchor, resolveMarkdownDocLink } from './markdown-doc-links' +import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -40,7 +40,9 @@ export function useMarkdownDocuments( onSave: (content: string) => Promise ): UseMarkdownDocumentsResult { const worktreeId = activeFile.worktreeId - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + // Why: PTY activity replaces worktree metadata; only a routing-path change + // should wake every mounted editor's document-link controller. + const worktreePath = useAppStore((s) => selectMarkdownDocumentWorktreePath(s, worktreeId)) const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState< @@ -48,13 +50,6 @@ export function useMarkdownDocuments( >({}) const requestRef = useRef(0) - const worktreePath = useMemo(() => { - if (!worktreeId) { - return null - } - return findWorktreeById(worktreesByRepo, worktreeId)?.path ?? null - }, [worktreeId, worktreesByRepo]) - const connectionId = getConnectionId(worktreeId) const refreshMarkdownDocuments = useCallback(async (): Promise => { From a4fb828b3c7c832aabe37b7302c1553c2cb901fd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:51:59 -0700 Subject: [PATCH 014/138] perf(renderer): scope folder review cache projections (#8136) * perf(renderer): scope folder review cache projections * fix(renderer): keep checks selector notifications constant-time --- .../FolderWorkspacePrChecksPanel.tsx | 20 +- ...rent-pr-checks-projection-selector.test.ts | 192 ++++++++++++++++++ .../parent-pr-checks-projection-selector.ts | 93 +++++++++ 3 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts create mode 100644 src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index 67b1e1b06ac..0c64561edec 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -8,15 +8,12 @@ import { translate } from '@/i18n/i18n' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow' -import { - buildParentPrChecksProjection, - type ParentPrChecksRefreshOutcome, - type ParentPrChecksRow -} from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-rows' import { getParentPrChecksRefreshCandidates, runLimitedParentPrChecksRefreshes } from './parent-pr-checks-refresh' +import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' type FolderWorkspacePrChecksPanelProps = { isVisible?: boolean @@ -33,9 +30,6 @@ export default function FolderWorkspacePrChecksPanel({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const repos = useAppStore((s) => s.repos) const settings = useAppStore((s) => s.settings) - const hostedReviewCache = useAppStore((s) => s.hostedReviewCache) - const prCache = useAppStore((s) => s.prCache) - const checksCache = useAppStore((s) => s.checksCache) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) const fetchPRChecks = useAppStore((s) => s.fetchPRChecks) const fetchPRCheckDetails = useAppStore((s) => s.fetchPRCheckDetails) @@ -65,19 +59,17 @@ export default function FolderWorkspacePrChecksPanel({ worktreesByRepo ] ) - const projection = useMemo( + const projectionSelector = useMemo( () => - buildParentPrChecksProjection({ + createParentPrChecksProjectionSelector({ worktrees: childWorktrees, repos, settings, - hostedReviewCache, - prCache, - checksCache, refreshOutcomes }), - [childWorktrees, repos, settings, hostedReviewCache, prCache, checksCache, refreshOutcomes] + [childWorktrees, repos, settings, refreshOutcomes] ) + const projection = useAppStore(projectionSelector) const folderWorkspaceId = folderWorkspace?.id ?? null const headerSummary = useMemo( () => formatReviewChecksHeaderSummary(projection.summary), diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts new file mode 100644 index 00000000000..75c288da7fd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo, Worktree } from '../../../../shared/types' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { buildParentPrChecksProjection } from './parent-pr-checks-rows' +import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' + +function repo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#fff', + addedAt: 1, + kind: 'git' + } +} + +function worktree(index: number): Worktree { + return { + id: `worktree-${index}`, + path: `/worktrees/${index}`, + head: `head-${index}`, + branch: `refs/heads/feature-${index}`, + isBare: false, + isMainWorktree: false, + repoId: 'repo-1', + displayName: `Worktree ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: index, + lastActivityAt: index + } +} + +function review(number: number): HostedReviewInfo { + return { + provider: 'github', + number, + title: `Review ${number}`, + state: 'open', + url: `https://example.test/review/${number}`, + status: 'success', + updatedAt: '2026-01-01T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'head-0' + } +} + +describe('parent PR checks projection selector', () => { + it('does not inspect tracked keys when cache map references are unchanged', () => { + const cacheRead = vi.fn() + const observedCache = new Proxy( + {}, + { + get: (target, property, receiver) => { + cacheRead(property) + return Reflect.get(target, property, receiver) + } + } + ) + const buildProjection = vi.fn(buildParentPrChecksProjection) + const select = createParentPrChecksProjectionSelector( + { worktrees: [worktree(0)], repos: [repo()], settings: null, refreshOutcomes: new Map() }, + buildProjection + ) + const state = { + hostedReviewCache: observedCache, + prCache: observedCache, + checksCache: observedCache + } + const projection = select(state) + cacheRead.mockClear() + + for (let notification = 0; notification < 1_000; notification += 1) { + expect(select(state)).toBe(projection) + } + + expect(cacheRead).not.toHaveBeenCalled() + expect(buildProjection).toHaveBeenCalledTimes(1) + }) + + it('ignores unrelated global review-cache replacements at scale', () => { + const worktrees = Array.from({ length: 100 }, (_, index) => worktree(index)) + const buildProjection = vi.fn(buildParentPrChecksProjection) + const select = createParentPrChecksProjectionSelector( + { worktrees, repos: [repo()], settings: null, refreshOutcomes: new Map() }, + buildProjection + ) + let state = { hostedReviewCache: {}, prCache: {}, checksCache: {} } + let projection = select(state) + let wholeMapInvalidations = 0 + let scopedInvalidations = 0 + + for (let write = 0; write < 200; write += 1) { + const previous = state + state = { + ...state, + hostedReviewCache: { + ...state.hostedReviewCache, + [`unrelated-hosted-${write}`]: { data: null, fetchedAt: write } + } + } + wholeMapInvalidations += Number(previous.hostedReviewCache !== state.hostedReviewCache) + const next = select(state) + scopedInvalidations += Number(projection !== next) + projection = next + } + for (let write = 0; write < 200; write += 1) { + const previous = state + state = { + ...state, + prCache: { + ...state.prCache, + [`unrelated-pr-${write}`]: { data: null, fetchedAt: write } + } + } + wholeMapInvalidations += Number(previous.prCache !== state.prCache) + const next = select(state) + scopedInvalidations += Number(projection !== next) + projection = next + } + for (let write = 0; write < 200; write += 1) { + const previous = state + state = { + ...state, + checksCache: { + ...state.checksCache, + [`unrelated-checks-${write}`]: { data: [], fetchedAt: write } + } + } + wholeMapInvalidations += Number(previous.checksCache !== state.checksCache) + const next = select(state) + scopedInvalidations += Number(projection !== next) + projection = next + } + + expect(wholeMapInvalidations).toBe(600) + expect(scopedInvalidations).toBe(0) + expect(buildProjection).toHaveBeenCalledTimes(1) + }) + + it('rebuilds when a previously missing relevant entry appears', () => { + const activeRepo = repo() + const activeWorktree = worktree(0) + const buildProjection = vi.fn(buildParentPrChecksProjection) + const select = createParentPrChecksProjectionSelector( + { + worktrees: [activeWorktree], + repos: [activeRepo], + settings: null, + refreshOutcomes: new Map() + }, + buildProjection + ) + const initialState = { hostedReviewCache: {}, prCache: {}, checksCache: {} } + const initial = select(initialState) + const relevantKey = getHostedReviewCacheKey( + activeRepo.path, + 'feature-0', + null, + activeRepo.id, + activeRepo.connectionId, + activeRepo.executionHostId, + true + ) + const reviewEntry = { data: review(7), fetchedAt: 1, linkedReviewHintKey: '' } + const relevantState = { + ...initialState, + hostedReviewCache: { [relevantKey]: reviewEntry } + } + const updated = select(relevantState) + + expect(updated).not.toBe(initial) + expect(updated.rows[0]?.reviewLabel).toBe('#7') + expect(buildProjection).toHaveBeenCalledTimes(2) + + const unrelatedState = { + ...relevantState, + checksCache: { unrelated: { data: [], fetchedAt: 2 } } + } + expect(select(unrelatedState)).toBe(updated) + expect(buildProjection).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts new file mode 100644 index 00000000000..02677575e48 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts @@ -0,0 +1,93 @@ +import type { AppState } from '@/store/types' +import { buildParentPrChecksProjection } from './parent-pr-checks-rows' +import type { + BuildParentPrChecksRowsArgs, + ParentPrChecksProjection +} from './parent-pr-checks-row-types' + +type ReviewCacheState = Pick +type ReviewCacheName = keyof ReviewCacheState +type ProjectionInputs = Omit< + BuildParentPrChecksRowsArgs, + 'checksCache' | 'hostedReviewCache' | 'prCache' +> +type ProjectionBuilder = (args: BuildParentPrChecksRowsArgs) => ParentPrChecksProjection +type CacheDependency = { + cacheName: ReviewCacheName + key: string + value: unknown +} + +function trackCacheReads( + state: ReviewCacheState, + cacheName: K, + dependencies: CacheDependency[] +): ReviewCacheState[K] { + return new Proxy(state[cacheName], { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver) + if (typeof property === 'string') { + dependencies.push({ cacheName, key: property, value }) + } + return value + } + }) +} + +function dependenciesAreCurrent( + state: ReviewCacheState, + previousState: ReviewCacheState, + dependencies: readonly CacheDependency[] +): boolean { + return dependencies.every( + ({ cacheName, key, value }) => + state[cacheName] === previousState[cacheName] || Reflect.get(state[cacheName], key) === value + ) +} + +function cacheReferencesAreCurrent( + state: ReviewCacheState, + previousState: ReviewCacheState +): boolean { + return ( + state.hostedReviewCache === previousState.hostedReviewCache && + state.prCache === previousState.prCache && + state.checksCache === previousState.checksCache + ) +} + +export function createParentPrChecksProjectionSelector( + inputs: ProjectionInputs, + buildProjection: ProjectionBuilder = buildParentPrChecksProjection +): (state: ReviewCacheState) => ParentPrChecksProjection { + let cached: { + cacheReferences: ReviewCacheState + dependencies: CacheDependency[] + projection: ParentPrChecksProjection + } | null = null + + return (state) => { + if (cached) { + if (cacheReferencesAreCurrent(state, cached.cacheReferences)) { + return cached.projection + } + if (dependenciesAreCurrent(state, cached.cacheReferences, cached.dependencies)) { + // Why: adopting unrelated replacement maps keeps later store notifications O(1). + cached.cacheReferences = state + return cached.projection + } + } + + const dependencies: CacheDependency[] = [] + // Why: global provider refreshes replace these maps frequently; track only + // cache keys the attached-worktree projection actually reads. + const projection = buildProjection({ + ...inputs, + hostedReviewCache: trackCacheReads(state, 'hostedReviewCache', dependencies), + prCache: trackCacheReads(state, 'prCache', dependencies), + checksCache: trackCacheReads(state, 'checksCache', dependencies) + }) + cached = { cacheReferences: state, dependencies, projection } + return projection + } +} From 141b9b7de2871dffc68aa34200eb891c5b4e4f5c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:13 -0700 Subject: [PATCH 015/138] perf(renderer): gate agent completion coordinator sync (#8149) --- ...gent-hook-completion-notifications.test.ts | 31 +++ .../agent-hook-completion-notifications.ts | 20 +- .../agent-hook-completion-store-sync.test.ts | 190 ++++++++++++++++++ .../hooks/agent-hook-completion-store-sync.ts | 112 +++++++++++ src/renderer/src/hooks/useIpcEvents.test.ts | 14 +- src/renderer/src/hooks/useIpcEvents.ts | 6 +- 6 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-store-sync.ts diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 3fa220ffaff..75487c039b9 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -824,6 +824,37 @@ describe('agent hook completion notifications', () => { expect(_getAgentHookCompletionNotificationCoordinatorCountForTest()).toBe(3) }) + it('gates cosmetic store updates but still prunes after a pane closes', async () => { + const { + _getAgentHookCompletionNotificationCoordinatorCountForTest, + observeAgentHookCompletionForNotification, + syncAgentHookCompletionNotificationsForStoreUpdate + } = await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + + const beforeCosmeticUpdate = { ...mockStoreState } + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] + } + expect( + syncAgentHookCompletionNotificationsForStoreUpdate(mockStoreState, beforeCosmeticUpdate) + ).toBe(false) + expect(_getAgentHookCompletionNotificationCoordinatorCountForTest()).toBe(1) + + const beforeClose = { ...mockStoreState } + mockStoreState.tabsByWorktree = { 'wt-1': [] } + mockStoreState.ptyIdsByTabId = {} + expect(syncAgentHookCompletionNotificationsForStoreUpdate(mockStoreState, beforeClose)).toBe( + true + ) + expect(_getAgentHookCompletionNotificationCoordinatorCountForTest()).toBe(0) + }) + it('skips tab scans until a pane-liveness slice changes', async () => { seedManyLivePanes() const { diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 0154d5d0d4a..125c0994514 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -10,6 +10,10 @@ import { dispatchTerminalNotification } from '@/components/terminal-pane/use-not import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' import { createCodexAutoApprovalHookCompletionSuppressor } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-hook-terminal-lifecycle' +import { + shouldSyncAgentHookCompletionForStoreUpdate, + type AgentHookCompletionStoreSnapshot +} from './agent-hook-completion-store-sync' type CoordinatorEntry = { worktreeId: string @@ -19,7 +23,8 @@ type CoordinatorEntry = { type StoreSnapshot = ReturnType type WorktreeTab = NonNullable[string][number] // Why: a paneKey resolves to a tab by id. Prebuilding this index once per prune -// pass avoids re-flattening tabsByWorktree per coordinator (O(coordinators x tabs)). +// pass avoids re-flattening tabsByWorktree per coordinator (O(coordinators x +// tabs)) when a liveness or notification-setting update requires a prune. type TabIndex = ReadonlyMap type PaneCoordinatorLivenessSnapshot = Pick< StoreSnapshot, @@ -119,6 +124,19 @@ export function syncAgentHookCompletionNotificationSettings(): boolean { return enabled } +export function syncAgentHookCompletionNotificationsForStoreUpdate( + current: AgentHookCompletionStoreSnapshot, + previous: AgentHookCompletionStoreSnapshot +): boolean { + // Why: Zustand also publishes high-rate title/status writes that cannot make + // module-scoped completion coordinators stale. + if (!shouldSyncAgentHookCompletionForStoreUpdate(current, previous)) { + return false + } + syncAgentHookCompletionNotificationSettings() + return true +} + function getPtyIdForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) if (!parsed) { diff --git a/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts b/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts new file mode 100644 index 00000000000..de1f22eca11 --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + _measureAgentHookCompletionStoreSyncForTest, + shouldSyncAgentHookCompletionForStoreUpdate, + type AgentHookCompletionStoreSnapshot +} from './agent-hook-completion-store-sync' + +function createState( + overrides: Partial = {} +): AgentHookCompletionStoreSnapshot { + return { + settings: { + experimentalTerminalAttention: false, + notifications: { enabled: true, agentTaskComplete: true } + }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', title: 'Terminal 1' }] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: {}, + suppressedPtyExitIds: {}, + ...overrides + } +} + +describe('agent hook completion store sync', () => { + it('ignores unrelated and title-only writes at accumulated-workspace scale', () => { + const tabCount = 100 + const coordinatorCount = 100 + const updateCount = 600 + const tabsByWorktree = Object.fromEntries( + Array.from({ length: tabCount }, (_, index) => [ + `wt-${index}`, + [{ id: `tab-${index}`, ptyId: `pty-${index}`, title: `Terminal ${index}` }] + ]) + ) + let previous = createState({ tabsByWorktree }) + let syncPasses = 0 + let tabVisits = 0 + + for (let index = 0; index < updateCount; index += 1) { + const current = { ...previous } + const measurement = _measureAgentHookCompletionStoreSyncForTest(current, previous) + syncPasses += Number(measurement.shouldSync) + tabVisits += measurement.tabVisits + previous = current + } + + for (let index = 0; index < updateCount; index += 1) { + const targetTabs = previous.tabsByWorktree['wt-99'] + if (!targetTabs) { + throw new Error('Expected title-update fixture tab') + } + const current = createState({ + ...previous, + tabsByWorktree: { + ...previous.tabsByWorktree, + 'wt-99': targetTabs.map((tab) => ({ ...tab, title: `Agent frame ${index}` })) + } + }) + const measurement = _measureAgentHookCompletionStoreSyncForTest(current, previous) + syncPasses += Number(measurement.shouldSync) + tabVisits += measurement.tabVisits + previous = current + } + + const previousFullPassCost = { + tabVisits: updateCount * 2 * tabCount, + coordinatorVisits: updateCount * 2 * coordinatorCount + } + const gatedCost = { + tabVisits, + coordinatorVisits: syncPasses * coordinatorCount + } + expect(previousFullPassCost).toEqual({ tabVisits: 120_000, coordinatorVisits: 120_000 }) + expect(gatedCost).toEqual({ tabVisits: 600, coordinatorVisits: 0 }) + }) + + it('keeps every coordinator-liveness input reactive', () => { + const previous = createState() + const titleOnly = createState({ + ...previous, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', title: 'Codex working' }] + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(titleOnly, previous)).toBe(false) + + const tabCreated = createState({ + ...previous, + tabsByWorktree: { + ...previous.tabsByWorktree, + 'wt-2': [{ id: 'tab-2', ptyId: null }] + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(tabCreated, previous)).toBe(true) + + const tabRemoved = createState({ ...previous, tabsByWorktree: {} }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(tabRemoved, previous)).toBe(true) + + const tabPtyChanged = createState({ + ...previous, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-2' }] } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(tabPtyChanged, previous)).toBe(true) + + expect( + shouldSyncAgentHookCompletionForStoreUpdate( + createState({ ...previous, ptyIdsByTabId: { 'tab-1': ['pty-2'] } }), + previous + ) + ).toBe(true) + expect( + shouldSyncAgentHookCompletionForStoreUpdate( + createState({ ...previous, terminalLayoutsByTabId: { 'tab-1': { root: null } } }), + previous + ) + ).toBe(true) + expect( + shouldSyncAgentHookCompletionForStoreUpdate( + createState({ ...previous, suppressedPtyExitIds: { 'pty-1': true } }), + previous + ) + ).toBe(true) + + const trackingDisabled = createState({ + ...previous, + settings: { + experimentalTerminalAttention: false, + notifications: { enabled: false, agentTaskComplete: false } + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(trackingDisabled, previous)).toBe(true) + }) + + it('treats tab order and duplicate-id worktree precedence as liveness inputs', () => { + const previous = createState({ + tabsByWorktree: { + 'wt-1': [{ id: 'tab-shared', ptyId: 'pty-first' }], + 'wt-2': [{ id: 'tab-shared', ptyId: 'pty-second' }] + } + }) + const reorderedWorktrees = createState({ + ...previous, + tabsByWorktree: { + 'wt-2': previous.tabsByWorktree['wt-2'] ?? [], + 'wt-1': previous.tabsByWorktree['wt-1'] ?? [] + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(reorderedWorktrees, previous)).toBe(true) + + const twoTabPrevious = createState({ + tabsByWorktree: { + 'wt-1': [ + { id: 'tab-1', ptyId: 'pty-1' }, + { id: 'tab-2', ptyId: 'pty-2' } + ] + } + }) + const reorderedTabs = createState({ + ...twoTabPrevious, + tabsByWorktree: { + 'wt-1': [ + { id: 'tab-2', ptyId: 'pty-2' }, + { id: 'tab-1', ptyId: 'pty-1' } + ] + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(reorderedTabs, twoTabPrevious)).toBe(true) + }) + + it('compares effective tracking state instead of unrelated settings identity', () => { + const previous = createState({ + settings: { + experimentalTerminalAttention: true, + notifications: { enabled: false, agentTaskComplete: false } + } + }) + const stillTrackedByNotifications = createState({ + ...previous, + settings: { + experimentalTerminalAttention: false, + notifications: { enabled: true, agentTaskComplete: true } + } + }) + expect(shouldSyncAgentHookCompletionForStoreUpdate(stillTrackedByNotifications, previous)).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-store-sync.ts b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts new file mode 100644 index 00000000000..e900c5b0c5d --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts @@ -0,0 +1,112 @@ +type CompletionNotificationSettings = { + readonly enabled?: boolean + readonly agentTaskComplete?: boolean +} + +type CompletionStoreSettings = { + readonly notifications?: CompletionNotificationSettings + readonly experimentalTerminalAttention?: boolean +} + +type CompletionTerminalTab = { + readonly id: string + readonly ptyId?: string | null + readonly title?: string +} + +export type AgentHookCompletionStoreSnapshot = { + readonly settings: CompletionStoreSettings | null + readonly tabsByWorktree: Readonly> + readonly ptyIdsByTabId: Readonly> + readonly terminalLayoutsByTabId: Readonly> + readonly suppressedPtyExitIds: Readonly> +} + +type TabVisit = () => void + +function isTrackingEnabled(state: AgentHookCompletionStoreSnapshot): boolean { + const notifications = state.settings?.notifications + const notificationEnabled = + notifications?.enabled !== false && notifications?.agentTaskComplete !== false + return notificationEnabled || state.settings?.experimentalTerminalAttention === true +} + +function terminalTabLivenessMatches( + current: AgentHookCompletionStoreSnapshot['tabsByWorktree'], + previous: AgentHookCompletionStoreSnapshot['tabsByWorktree'], + visitTab?: TabVisit +): boolean { + if (current === previous) { + return true + } + + const currentWorktreeIds = Object.keys(current) + const previousWorktreeIds = Object.keys(previous) + if (currentWorktreeIds.length !== previousWorktreeIds.length) { + return false + } + + for (const [worktreeIndex, worktreeId] of currentWorktreeIds.entries()) { + // Why: duplicate tab ids use first-worktree-wins lookup semantics, so a + // worktree-key reorder is a liveness change even when every array is reused. + if (previousWorktreeIds[worktreeIndex] !== worktreeId) { + return false + } + const currentTabs = current[worktreeId] + const previousTabs = previous[worktreeId] + if (currentTabs === previousTabs) { + continue + } + if (!currentTabs || !previousTabs || currentTabs.length !== previousTabs.length) { + return false + } + for (const [tabIndex, currentTab] of currentTabs.entries()) { + visitTab?.() + const previousTab = previousTabs[tabIndex] + if ( + !previousTab || + currentTab.id !== previousTab.id || + currentTab.ptyId !== previousTab.ptyId + ) { + return false + } + } + } + return true +} + +function shouldSync( + current: AgentHookCompletionStoreSnapshot, + previous: AgentHookCompletionStoreSnapshot, + visitTab?: TabVisit +): boolean { + if (isTrackingEnabled(current) !== isTrackingEnabled(previous)) { + return true + } + if ( + current.ptyIdsByTabId !== previous.ptyIdsByTabId || + current.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId || + current.suppressedPtyExitIds !== previous.suppressedPtyExitIds + ) { + return true + } + return !terminalTabLivenessMatches(current.tabsByWorktree, previous.tabsByWorktree, visitTab) +} + +export function shouldSyncAgentHookCompletionForStoreUpdate( + current: AgentHookCompletionStoreSnapshot, + previous: AgentHookCompletionStoreSnapshot +): boolean { + return shouldSync(current, previous) +} + +export function _measureAgentHookCompletionStoreSyncForTest( + current: AgentHookCompletionStoreSnapshot, + previous: AgentHookCompletionStoreSnapshot +): { shouldSync: boolean; tabVisits: number } { + let tabVisits = 0 + const requiresSync = shouldSync(current, previous, () => { + tabVisits += 1 + }) + return { shouldSync: requiresSync, tabVisits } +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 7abf34a6d3e..28d2aa993dd 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4299,7 +4299,7 @@ describe('useIpcEvents agent status snapshot integration', () => { stateStartedAt: number } type StoreLike = Record - type StoreSubscribeListener = (state: StoreLike) => void + type StoreSubscribeListener = (state: StoreLike, previousState: StoreLike) => void type MobileFitEvent = { ptyId: string mode: 'mobile-fit' | 'desktop-fit' @@ -4799,6 +4799,7 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).not.toHaveBeenCalled() expect(getSnapshot).not.toHaveBeenCalled() + const previousStoreState = { ...storeState } storeState.workspaceSessionReady = true storeState.tabsByWorktree = { 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] @@ -4813,7 +4814,7 @@ describe('useIpcEvents agent status snapshot integration', () => { if (typeof subscribeListenerRef.current !== 'function') { throw new Error('Expected useAppStore.subscribe listener to be registered') } - subscribeListenerRef.current(storeState) + subscribeListenerRef.current(storeState, previousStoreState) await Promise.resolve() expect(setAgentStatus).toHaveBeenCalledTimes(1) @@ -4937,7 +4938,7 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.doMock('./agent-hook-completion-notifications', () => ({ observeAgentHookCompletionForNotification, resetAgentHookCompletionNotificationCoordinators: vi.fn(), - syncAgentHookCompletionNotificationSettings: vi.fn() + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() })) stubAuxiliaryModules() vi.stubGlobal( @@ -5014,7 +5015,7 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.doMock('./agent-hook-completion-notifications', () => ({ observeAgentHookCompletionForNotification, resetAgentHookCompletionNotificationCoordinators: vi.fn(), - syncAgentHookCompletionNotificationSettings: vi.fn() + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() })) stubAuxiliaryModules() vi.stubGlobal( @@ -5169,7 +5170,7 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.doMock('./agent-hook-completion-notifications', () => ({ observeAgentHookCompletionForNotification, resetAgentHookCompletionNotificationCoordinators: vi.fn(), - syncAgentHookCompletionNotificationSettings: vi.fn() + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() })) stubAuxiliaryModules() vi.stubGlobal( @@ -5978,6 +5979,7 @@ describe('useIpcEvents agent status snapshot integration', () => { reason: 'unknown_tab_id' }) + const previousStoreState = { ...storeState } storeState.terminalLayoutsByTabId = { 'tab-future': { root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, @@ -5988,7 +5990,7 @@ describe('useIpcEvents agent status snapshot integration', () => { if (typeof subscribeListenerRef.current !== 'function') { throw new Error('Expected useAppStore.subscribe listener to be registered') } - subscribeListenerRef.current(storeState) + subscribeListenerRef.current(storeState, previousStoreState) expect(setAgentStatus).toHaveBeenCalledTimes(2) expect(setAgentStatus).toHaveBeenNthCalledWith( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 219f8dca783..2eb7f545d2b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -116,7 +116,7 @@ import { import { observeAgentHookCompletionForNotification, resetAgentHookCompletionNotificationCoordinators, - syncAgentHookCompletionNotificationSettings + syncAgentHookCompletionNotificationsForStoreUpdate } from './agent-hook-completion-notifications' import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' @@ -3212,10 +3212,10 @@ export function useIpcEvents(): void { // renderer state. requestAgentStatusSnapshotIfReady() unsubs.push( - useAppStore.subscribe(() => { + useAppStore.subscribe((state, previousState) => { requestAgentStatusSnapshotIfReady() flushPendingAgentStatuses() - syncAgentHookCompletionNotificationSettings() + syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) }) ) From 90afbc3cf3b949e30f7c729a53a5eb3d6b1aaf55 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:15 -0700 Subject: [PATCH 016/138] perf(status-bar): avoid resource badge rebuilds on title churn (#8152) --- .../status-bar/ResourceUsageStatusSegment.tsx | 22 +- .../resource-session-count-selector.test.ts | 219 ++++++++++++++++++ .../resource-session-count-selector.ts | 102 ++++++++ .../resource-usage-open-slices.test.ts | 30 +++ .../status-bar/resource-usage-open-slices.ts | 16 ++ 5 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/status-bar/resource-session-count-selector.test.ts create mode 100644 src/renderer/src/components/status-bar/resource-session-count-selector.ts diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index fffd83215ba..8d3ed2982e9 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -56,8 +56,10 @@ import { } from './resource-session-navigation' import { getResourceUsageAllWorktrees, + getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, + getResourceUsageTerminalLayoutsByTabId, getResourceUsageTabsByWorktree } from './resource-usage-open-slices' import { @@ -73,9 +75,11 @@ import { countUnboundDaemonSessions, type ResourceSessionBindingInputs } from './resource-session-bindings' +import { createClosedResourceSessionCountSelector } from './resource-session-count-selector' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 +const selectClosedResourceSessionCount = createClosedResourceSessionCountSelector() type SortOption = 'memory' | 'cpu' | 'name' @@ -737,9 +741,7 @@ export function ResourceUsageStatusSegment({ const memorySnapshotError = useAppStore((s) => s.memorySnapshotError) const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + const closedSessionCount = useAppStore(selectClosedResourceSessionCount) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -774,17 +776,21 @@ export function ResourceUsageStatusSegment({ const repos = useAppStore((s) => getResourceUsageRepos(s, open)) const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + // Why: the closed trigger owns a scalar selector. Full binding maps stay + // behind open sentinels so unchanged counts do not rerender the segment. + const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open)) + const terminalLayoutsByTabId = useAppStore((s) => getResourceUsageTerminalLayoutsByTabId(s, open)) const resourceSnapshot = snapshot // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource // Manager also reads restored wake hints, but only for classification. const resourceSessionBindings = useMemo( () => ({ ptyIdsByTabId, - tabsByWorktree: tabsByWorktreeForPtyBindings, + tabsByWorktree, terminalLayoutsByTabId, workspaceSessionReady }), - [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + [ptyIdsByTabId, tabsByWorktree, terminalLayoutsByTabId, workspaceSessionReady] ) // Why: after a kill confirms and the session unmounts, focus would otherwise @@ -975,12 +981,6 @@ export function ResourceUsageStatusSegment({ return countUnboundDaemonSessions(sessions, resourceSessionBindings) }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - const closedSessionCount = useMemo(() => { - if (!workspaceSessionReady) { - return 0 - } - return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size - }, [resourceSessionBindings, workspaceSessionReady]) const triggerSessionCount = open ? sessions.length : closedSessionCount const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts new file mode 100644 index 00000000000..7d416ff856b --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + type ResourceSessionBindingInputs +} from './resource-session-bindings' +import { + createClosedResourceSessionCountSelector, + type ClosedResourceSessionCountState +} from './resource-session-count-selector' + +const TAB_COUNT = 100 +const TITLE_WRITES = 600 + +function makeTab(id: string, ptyId: string | null = null, title = 'Terminal'): TerminalTab { + return { + id, + ptyId, + worktreeId: `wt-${id}`, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +function makeState( + overrides: Partial = {} +): ClosedResourceSessionCountState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true, + ...overrides + } +} + +function withCountedIteration(tabs: TerminalTab[], onVisit: () => void): TerminalTab[] { + return new Proxy(tabs, { + get(target, property, receiver) { + if (property !== Symbol.iterator) { + return Reflect.get(target, property, receiver) + } + return function* countedIterator(): Generator { + for (const tab of target) { + onVisit() + yield tab + } + } + } + }) +} + +function makeScaleTabs(onVisit: () => void): Record { + return Object.fromEntries( + Array.from({ length: TAB_COUNT }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, withCountedIteration([makeTab(`tab-${index}`, `pty-${index}`)], onVisit)] + }) + ) +} + +function countNextTabComparison( + tab: TerminalTab, + onComparison: () => void +): [TerminalTab[], () => void] { + let counting = true + const tabs = new Proxy([tab], { + get(target, property, receiver) { + if (counting && property === '0') { + onComparison() + } + return Reflect.get(target, property, receiver) + } + }) + return [ + tabs, + () => { + counting = false + } + ] +} + +describe('closed resource session count selector', () => { + it('removes full binding-index rebuilds from title-only tab churn at scale', () => { + let oldPathTabVisits = 0 + const initialTabs = makeScaleTabs(() => { + oldPathTabVisits += 1 + }) + const inputs: ResourceSessionBindingInputs = { + tabsByWorktree: initialTabs, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + + // Why: this models the previous useMemo dependency on tabsByWorktree: + // every title write replaces that map and rebuilds both tab passes. + for (let index = 0; index < TITLE_WRITES; index += 1) { + buildResourceSessionBindingIndex({ ...inputs, tabsByWorktree: { ...initialTabs } }) + } + expect(oldPathTabVisits).toBe(120_000) + + let optimizedTabVisits = 0 + const optimizedTabs = makeScaleTabs(() => { + optimizedTabVisits += 1 + }) + const buildIndex = vi.fn(buildResourceSessionBindingIndex) + const selectCount = createClosedResourceSessionCountSelector(buildIndex) + let state = makeState({ tabsByWorktree: optimizedTabs }) + const currentTabByWorktree = Object.fromEntries( + Object.entries(optimizedTabs).map(([worktreeId, tabs]) => [worktreeId, tabs[0]]) + ) + let changedArrayTabComparisons = 0 + + expect(selectCount(state)).toBe(TAB_COUNT) + buildIndex.mockClear() + optimizedTabVisits = 0 + + for (let index = 0; index < TITLE_WRITES; index += 1) { + const worktreeId = `wt-${index % TAB_COUNT}` + const nextTab = { ...currentTabByWorktree[worktreeId], title: `Terminal ${index}` } + const [nextTabs, stopCounting] = countNextTabComparison(nextTab, () => { + changedArrayTabComparisons += 1 + }) + state = { + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + [worktreeId]: nextTabs + } + } + expect(selectCount(state)).toBe(TAB_COUNT) + stopCounting() + currentTabByWorktree[worktreeId] = nextTab + } + + expect(buildIndex).not.toHaveBeenCalled() + expect(optimizedTabVisits).toBe(0) + expect(changedArrayTabComparisons).toBe(TITLE_WRITES) + }) + + it('reacts to every binding and readiness input while ignoring display-only tab fields', () => { + const buildIndex = vi.fn(buildResourceSessionBindingIndex) + const selectCount = createClosedResourceSessionCountSelector(buildIndex) + let state = makeState({ + workspaceSessionReady: false, + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'pty-wake')] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-live'] + }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout({ 'leaf-1': 'pty-layout' }) + } + }) + + expect(selectCount(state)).toBe(0) + expect(buildIndex).not.toHaveBeenCalled() + + state = { ...state, workspaceSessionReady: true } + expect(selectCount(state)).toBe(3) + expect(buildIndex).toHaveBeenCalledTimes(1) + + state = { + ...state, + tabsByWorktree: { + 'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], title: 'Working' }] + } + } + expect(selectCount(state)).toBe(3) + expect(buildIndex).toHaveBeenCalledTimes(1) + + state = { + ...state, + ptyIdsByTabId: { 'tab-1': ['pty-live', 'pty-live-2'] } + } + expect(selectCount(state)).toBe(4) + expect(buildIndex).toHaveBeenCalledTimes(2) + + state = { + ...state, + terminalLayoutsByTabId: { + 'tab-1': makeLayout({ 'leaf-1': 'pty-layout', 'leaf-2': 'pty-layout-2' }) + } + } + expect(selectCount(state)).toBe(5) + expect(buildIndex).toHaveBeenCalledTimes(3) + + state = { + ...state, + tabsByWorktree: { + 'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], ptyId: null }] + } + } + expect(selectCount(state)).toBe(4) + expect(buildIndex).toHaveBeenCalledTimes(4) + + state = { ...state, tabsByWorktree: {} } + expect(selectCount(state)).toBe(2) + expect(buildIndex).toHaveBeenCalledTimes(5) + + state = { ...state, workspaceSessionReady: false } + expect(selectCount(state)).toBe(0) + expect(buildIndex).toHaveBeenCalledTimes(5) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.ts new file mode 100644 index 00000000000..4fb924f4529 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../../store' +import { + buildResourceSessionBindingIndex, + type ResourceSessionBindingIndex, + type ResourceSessionBindingInputs +} from './resource-session-bindings' + +export type ClosedResourceSessionCountState = Pick< + AppState, + 'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'workspaceSessionReady' +> + +type BuildResourceSessionBindingIndex = ( + inputs: ResourceSessionBindingInputs +) => ResourceSessionBindingIndex + +export type ClosedResourceSessionCountSelector = (state: ClosedResourceSessionCountState) => number + +function haveSameTabBindings( + previous: AppState['tabsByWorktree'], + next: AppState['tabsByWorktree'] +): boolean { + if (previous === next) { + return true + } + + const previousWorktreeIds = Object.keys(previous) + const nextWorktreeIds = Object.keys(next) + if (previousWorktreeIds.length !== nextWorktreeIds.length) { + return false + } + + for (const worktreeId of nextWorktreeIds) { + const previousTabs = previous[worktreeId] + const nextTabs = next[worktreeId] + if (previousTabs === nextTabs) { + continue + } + if (!previousTabs || previousTabs.length !== nextTabs.length) { + return false + } + for (let index = 0; index < nextTabs.length; index += 1) { + const previousTab = previousTabs[index] + const nextTab = nextTabs[index] + // Why: the closed badge counts PTY ownership only. Titles and other + // display fields can churn per terminal frame without changing it. + if (previousTab.id !== nextTab.id || previousTab.ptyId !== nextTab.ptyId) { + return false + } + } + } + + return true +} + +export function createClosedResourceSessionCountSelector( + buildBindingIndex: BuildResourceSessionBindingIndex = buildResourceSessionBindingIndex +): ClosedResourceSessionCountSelector { + // Why: Zustand runs selectors for every store notification. Keep the last + // liveness inputs here so unrelated and title-only writes stay scalar-cheap. + let initialized = false + let previousTabsByWorktree: AppState['tabsByWorktree'] = {} + let previousPtyIdsByTabId: AppState['ptyIdsByTabId'] = {} + let previousTerminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {} + let previousWorkspaceSessionReady = false + let count = 0 + + return (state): number => { + const bindingMapChanged = + state.ptyIdsByTabId !== previousPtyIdsByTabId || + state.terminalLayoutsByTabId !== previousTerminalLayoutsByTabId + const readinessChanged = state.workspaceSessionReady !== previousWorkspaceSessionReady + const tabsReferenceChanged = state.tabsByWorktree !== previousTabsByWorktree + let tabBindingsChanged = tabsReferenceChanged + if ( + initialized && + state.workspaceSessionReady && + !bindingMapChanged && + !readinessChanged && + tabsReferenceChanged + ) { + tabBindingsChanged = !haveSameTabBindings(previousTabsByWorktree, state.tabsByWorktree) + } + + const shouldRebuild = + state.workspaceSessionReady && + (!initialized || bindingMapChanged || readinessChanged || tabBindingsChanged) + + if (shouldRebuild) { + count = buildBindingIndex(state).boundPtyIds.size + } else if (!state.workspaceSessionReady) { + count = 0 + } + + previousTabsByWorktree = state.tabsByWorktree + previousPtyIdsByTabId = state.ptyIdsByTabId + previousTerminalLayoutsByTabId = state.terminalLayoutsByTabId + previousWorkspaceSessionReady = state.workspaceSessionReady + initialized = true + return count + } +} diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 8fd0fef6d7d..eed3bd0b3f3 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { getResourceUsageAllWorktrees, + getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, + getResourceUsageTerminalLayoutsByTabId, getResourceUsageTabsByWorktree } from './resource-usage-open-slices' import type { AppState } from '../../store' @@ -41,11 +43,21 @@ const worktree = (): AppState['worktreesByRepo'][string][number] => ({ describe('resource usage open slices', () => { it('returns stable empty slices while the popover is closed', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } const runtimePaneTitlesByTabId = { 'tab-1': { 'tab-1:0': 'Working' } } as AppState['runtimePaneTitlesByTabId'] const closedTabs = getResourceUsageTabsByWorktree({ tabsByWorktree }, false) + const closedPtyIds = getResourceUsagePtyIdsByTabId({ ptyIdsByTabId }, false) + const closedLayouts = getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId }, false) const closedTitles = getResourceUsageRuntimePaneTitlesByTabId( { runtimePaneTitlesByTabId }, false @@ -55,17 +67,35 @@ describe('resource usage open slices', () => { expect(closedTitles).toBe( getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId: {} }, false) ) + expect(closedPtyIds).toBe(getResourceUsagePtyIdsByTabId({ ptyIdsByTabId: {} }, false)) + expect(closedLayouts).toBe( + getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId: {} }, false) + ) expect(closedTabs).toEqual({}) + expect(closedPtyIds).toEqual({}) + expect(closedLayouts).toEqual({}) expect(closedTitles).toEqual({}) }) it('returns live slices while the popover is open', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } const runtimePaneTitlesByTabId = { 'tab-1': { 'tab-1:0': 'Working' } } as AppState['runtimePaneTitlesByTabId'] expect(getResourceUsageTabsByWorktree({ tabsByWorktree }, true)).toBe(tabsByWorktree) + expect(getResourceUsagePtyIdsByTabId({ ptyIdsByTabId }, true)).toBe(ptyIdsByTabId) + expect(getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId }, true)).toBe( + terminalLayoutsByTabId + ) expect(getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId }, true)).toBe( runtimePaneTitlesByTabId ) diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index e74a79e2e10..7b5e7ba6d5b 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -2,6 +2,8 @@ import type { AppState } from '../../store' import { getAllWorktreesFromState } from '../../store/selectors' const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} +const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] @@ -13,6 +15,20 @@ export function getResourceUsageTabsByWorktree( return open ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE } +export function getResourceUsagePtyIdsByTabId( + state: Pick, + open: boolean +): AppState['ptyIdsByTabId'] { + return open ? state.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID +} + +export function getResourceUsageTerminalLayoutsByTabId( + state: Pick, + open: boolean +): AppState['terminalLayoutsByTabId'] { + return open ? state.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID +} + export function getResourceUsageRuntimePaneTitlesByTabId( state: Pick, open: boolean From 943fc781bb7d6b88e90851a9be2ba98ffe7402b7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:17 -0700 Subject: [PATCH 017/138] perf(runtime): reuse unchanged PTY path history (#8163) --- config/reliability-gates.jsonc | 34 +++++++++++--- ...rca-runtime-path-candidate-history.test.ts | 47 +++++++++++++++++++ src/main/runtime/orca-runtime.ts | 8 +++- 3 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/orca-runtime-path-candidate-history.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 30940476103..0c4df07a2a1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1221,6 +1221,7 @@ "surfaces": [ "daemon stream", "main PTY batching", + "runtime path provenance", "renderer ACK", "xterm scheduler", "hidden output" @@ -1240,20 +1241,22 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the main-process pending-output caps that exist on main@1282f5c2d. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", + "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts" ], "testFiles": [ - "src/main/ipc/pty.test.ts" + "src/main/ipc/pty.test.ts", + "src/main/runtime/orca-runtime-path-candidate-history.test.ts" ], "assertionRefs": [ { @@ -1263,6 +1266,13 @@ "active PTY pending output is prioritized during renderer backpressure", "combined pending output exceeding the interactive size limit is batched" ] + }, + { + "file": "src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "assertions": [ + "reuses path-candidate history across repeated pathless PTY output", + "copies history when new output adds a path candidate" + ] } ], "evidenceRuns": [ @@ -1274,6 +1284,15 @@ "result": "passed", "durationSeconds": 1.5, "summary": "1 test file(s) passed, 209 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "result": "passed", + "durationSeconds": 3.49, + "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." } ], "runtimeBudget": { @@ -1286,11 +1305,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Existing renderer tests cover replay/backlog slices. Needs intentional-break proof and broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; the fixed shape passed in 6ms. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, - "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios." + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements." }, "promotionCriteria": [ "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", @@ -1299,6 +1318,7 @@ ], "knownGaps": [ "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." ], diff --git a/src/main/runtime/orca-runtime-path-candidate-history.test.ts b/src/main/runtime/orca-runtime-path-candidate-history.test.ts new file mode 100644 index 00000000000..80b09ed81e3 --- /dev/null +++ b/src/main/runtime/orca-runtime-path-candidate-history.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendRecentPtyPathCandidates } from './orca-runtime' + +function fullCandidateHistory(): string[] { + return Array.from( + { length: 1024 }, + (_, index) => `/tmp/artifact-${index.toString().padStart(4, '0')}.json` + ) +} + +describe('PTY path candidate history', () => { + it('reuses path-candidate history across repeated pathless PTY output', () => { + const previous = fullCandidateHistory() + const byteLengthSpy = vi.spyOn(Buffer, 'byteLength') + let candidates = previous + let replacements = 0 + let byteLengthCalls = 0 + try { + for (let index = 0; index < 4096; index += 1) { + const next = appendRecentPtyPathCandidates( + candidates, + 'ordinary compiler progress without a path\n' + ) + if (next !== candidates) { + replacements += 1 + } + candidates = next + } + } finally { + byteLengthCalls = byteLengthSpy.mock.calls.length + byteLengthSpy.mockRestore() + } + + expect(candidates).toBe(previous) + expect(replacements).toBe(0) + expect(byteLengthCalls).toBe(0) + }) + + it('copies history when new output adds a path candidate', () => { + const previous = fullCandidateHistory() + const changed = appendRecentPtyPathCandidates(previous, 'wrote /tmp/new-artifact.json\n') + + expect(changed).not.toBe(previous) + expect(changed).toContain('/tmp/new-artifact.json') + expect(previous).not.toContain('/tmp/new-artifact.json') + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b7e4c1d2c30..ddeb56f9aa5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -24208,8 +24208,14 @@ export function appendRecentPtyPathCandidates( previous: string[] | undefined, data: string ): string[] { + const extractedCandidates = extractTerminalOutputPathCandidates(data) + if (extractedCandidates.length === 0) { + // Why: pathless output is the common hot path. Reuse immutable history so + // each PTY chunk does not clone and byte-scan up to 1,024 old candidates. + return previous ?? [] + } const next = previous ? previous.slice() : [] - for (const candidate of extractTerminalOutputPathCandidates(data)) { + for (const candidate of extractedCandidates) { if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { continue } From 435de9410254e6a55353d7426cc847f8152ba0f7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:22 -0700 Subject: [PATCH 018/138] perf(main): avoid hosted-review IPC fanout (#8171) --- .../github/pr-refresh-coordinator.test.ts | 33 +++++++ src/main/github/pr-refresh-coordinator.ts | 7 +- src/main/ipc/github.test.ts | 87 +++++++++------- src/main/ipc/github.ts | 21 +--- src/main/ipc/ui.test.ts | 98 +++++++++++++++++-- src/main/ipc/ui.ts | 20 +++- src/preload/index.ts | 7 +- .../src/components/GitHubItemDialog.tsx | 6 +- .../src/components/PullRequestPage.tsx | 6 +- 9 files changed, 208 insertions(+), 77 deletions(-) diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 3e53d0116d1..5ee1a882a62 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -5,12 +5,14 @@ import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' const { sendMock, + sendToTrustedUIRendererMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock, rateLimitGuardMock } = vi.hoisted(() => ({ sendMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), getAllWebContentsMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), getRateLimitMock: vi.fn(), @@ -33,6 +35,10 @@ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock })) +vi.mock('../ipc/ui', () => ({ + sendToTrustedUIRenderer: sendToTrustedUIRendererMock +})) + function makeCandidate( overrides: Partial = {} ): GitHubPRRefreshCandidate { @@ -82,6 +88,10 @@ describe('pr-refresh-coordinator', () => { vi.useFakeTimers() vi.setSystemTime(1_000) sendMock.mockReset() + sendToTrustedUIRendererMock.mockReset() + sendToTrustedUIRendererMock.mockImplementation((channel, payload) => { + sendMock(channel, payload) + }) getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRateLimitMock.mockReset() @@ -101,6 +111,29 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + + enqueuePRRefresh(makeCandidate({ isBare: true }), 'manual') + + expect(sendToTrustedUIRendererMock).toHaveBeenCalledOnce() + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:prRefreshEvent', + expect.objectContaining({ status: 'skipped', skippedReason: 'bare' }) + ) + expect(sendMock).toHaveBeenCalledOnce() + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) + }) + it('forwards the candidate worktree head into the branch lookup options', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8065fbb8145..bd59fc77cf2 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -13,6 +13,7 @@ import type { import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' type QueueEntry = { key: string @@ -211,11 +212,7 @@ function nextQueueOrder(): number { function broadcast(event: Omit, sequenceOverride?: number): void { const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - for (const wc of webContents.getAllWebContents()) { - if (!wc.isDestroyed()) { - wc.send('gh:prRefreshEvent', payload) - } - } + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) } function refreshKey(candidate: GitHubPRRefreshCandidate): string { diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 768981cec19..fef0cf7aace 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -47,6 +47,7 @@ const { trackMock, getCohortAtEmitMock, getAllWebContentsMock, + sendToTrustedUIRendererMock, clearVisiblePRRefreshWindowMock, enqueuePRRefreshMock, refreshPRNowMock, @@ -88,6 +89,7 @@ const { trackMock: vi.fn(), getCohortAtEmitMock: vi.fn(), getAllWebContentsMock: vi.fn(), + sendToTrustedUIRendererMock: vi.fn(), clearVisiblePRRefreshWindowMock: vi.fn(), enqueuePRRefreshMock: vi.fn(), refreshPRNowMock: vi.fn(), @@ -157,6 +159,10 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('./ui', () => ({ + sendToTrustedUIRenderer: sendToTrustedUIRendererMock +})) + import { registerGitHubHandlers } from './github' import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' @@ -233,6 +239,7 @@ describe('registerGitHubHandlers', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) getAllWebContentsMock.mockReset() getAllWebContentsMock.mockReturnValue([]) + sendToTrustedUIRendererMock.mockReset() clearVisiblePRRefreshWindowMock.mockReset() enqueuePRRefreshMock.mockReset() refreshPRNowMock.mockReset() @@ -282,12 +289,15 @@ describe('registerGitHubHandlers', () => { ) }) - it('broadcasts mutation notifications by repo id even when the payload path is remote', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets mutation notifications without broadcasting to 100 browser guests', async () => { + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) registerGitHubHandlers(store as never, stats as never) const result = await handlers['gh:notifyWorkItemMutated']( @@ -301,20 +311,22 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'pr', - number: 42 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledOnce() + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'pr', + number: 42 + }, + 1 + ) + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) }) - it('broadcasts mutation notifications with resolved repo id when called by repo path', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets mutation notifications with resolved repo id when called by repo path', async () => { registerGitHubHandlers(store as never, stats as never) const result = await handlers['gh:notifyWorkItemMutated']( @@ -327,20 +339,19 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'issue', - number: 7 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'issue', + number: 7 + }, + 1 + ) }) - it('broadcasts PR file viewed mutations with repo id for sibling cache invalidation', async () => { - const sendMock = vi.fn() - getAllWebContentsMock.mockReturnValue([ - { id: 1, isDestroyed: () => false, send: vi.fn() }, - { id: 2, isDestroyed: () => false, send: sendMock } - ]) + it('targets PR file viewed mutations with repo id for cache invalidation', async () => { setPRFileViewedMock.mockResolvedValue(true) registerGitHubHandlers(store as never, stats as never) @@ -356,12 +367,16 @@ describe('registerGitHubHandlers', () => { ) expect(result).toBe(true) - expect(sendMock).toHaveBeenCalledWith('gh:workItemMutated', { - repoPath: '/workspace/repo', - repoId: 'repo-1', - type: 'pr', - number: 42 - }) + expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( + 'gh:workItemMutated', + { + repoPath: '/workspace/repo', + repoId: 'repo-1', + type: 'pr', + number: 42 + }, + 1 + ) }) it('rejects unknown repository paths', async () => { diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 0d64c2512bf..a95fb887e9c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -2,7 +2,7 @@ the repo-path validation, preference-threading, and stats wiring patterns are reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ -import { ipcMain, webContents } from 'electron' +import { ipcMain } from 'electron' import { resolve } from 'node:path' import type { Repo, @@ -108,15 +108,12 @@ import type { import { appStarSourceSchema } from '../../shared/gh-star-source' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { sendToTrustedUIRenderer } from './ui' const prRefreshVisibilityCleanupRegistered = new Set() -// Why: notify every renderer (each window has its own SWR cache instance) -// that a work item was mutated locally so they can drop their cached entry -// and refetch on the next open. Only emitted after a successful mutation. -// We skip the originating webContents because that renderer already updated -// its cache optimistically — re-broadcasting would race the optimistic write -// and erase it. +// Why: the app renderer owns the SWR cache; browser guests cannot consume this +// event. Skip the origin because it already updated its cache optimistically. function broadcastWorkItemMutated( payload: { repoPath: string @@ -126,15 +123,7 @@ function broadcastWorkItemMutated( }, senderId?: number ): void { - for (const wc of webContents.getAllWebContents()) { - if (wc.isDestroyed()) { - continue - } - if (senderId !== undefined && wc.id === senderId) { - continue - } - wc.send('gh:workItemMutated', payload) - } + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) } // Why: returns the full Repo object instead of just the path string so that diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts index 4435c1c77e3..806d0fa5ab3 100644 --- a/src/main/ipc/ui.test.ts +++ b/src/main/ipc/ui.test.ts @@ -1,13 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { fromWebContentsMock, getAllWindowsMock, handleMock, onMock, removeAllListenersMock } = - vi.hoisted(() => ({ - fromWebContentsMock: vi.fn(), - getAllWindowsMock: vi.fn(() => []), - handleMock: vi.fn(), - onMock: vi.fn(), - removeAllListenersMock: vi.fn() - })) +const { + fromIdMock, + fromWebContentsMock, + getAllWebContentsMock, + getAllWindowsMock, + handleMock, + onMock, + removeAllListenersMock +} = vi.hoisted(() => ({ + fromIdMock: vi.fn(), + fromWebContentsMock: vi.fn(), + getAllWebContentsMock: vi.fn(), + getAllWindowsMock: vi.fn(() => []), + handleMock: vi.fn(), + onMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) vi.mock('electron', () => ({ BrowserWindow: { @@ -18,12 +27,17 @@ vi.mock('electron', () => ({ handle: handleMock, on: onMock, removeAllListeners: removeAllListenersMock + }, + webContents: { + fromId: fromIdMock, + getAllWebContents: getAllWebContentsMock } })) import { clearTrustedUIRendererWebContentsId, registerUIHandlers, + sendToTrustedUIRenderer, setTrustedUIRendererWebContentsId } from './ui' @@ -56,10 +70,13 @@ function getNativePasteHandler(): return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1] } -describe('registerUIHandlers', () => { +describe('UI IPC', () => { beforeEach(() => { vi.stubEnv('ELECTRON_RENDERER_URL', '') + fromIdMock.mockReset() fromWebContentsMock.mockReset() + getAllWebContentsMock.mockReset() + getAllWebContentsMock.mockReturnValue([]) getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) handleMock.mockReset() @@ -72,6 +89,69 @@ describe('registerUIHandlers', () => { vi.unstubAllEnvs() }) + it('sends app events once to the trusted renderer without waking 100 browser guests', () => { + const rendererSend = vi.fn() + const guestSends = Array.from({ length: 100 }, () => vi.fn()) + getAllWebContentsMock.mockReturnValue( + guestSends.map((send, index) => ({ + id: index + 100, + isDestroyed: () => false, + send + })) + ) + fromIdMock.mockReturnValue({ id: 17, isDestroyed: () => false, send: rendererSend }) + setTrustedUIRendererWebContentsId(17) + + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 1 }) + + expect(fromIdMock).toHaveBeenCalledOnce() + expect(fromIdMock).toHaveBeenCalledWith(17) + expect(rendererSend).toHaveBeenCalledOnce() + expect(rendererSend).toHaveBeenCalledWith('gh:prRefreshEvent', { sequence: 1 }) + expect(getAllWebContentsMock).not.toHaveBeenCalled() + expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0) + }) + + it('skips missing, destroyed, and originating renderers', () => { + const rendererSend = vi.fn() + setTrustedUIRendererWebContentsId(17) + + fromIdMock.mockReturnValueOnce(undefined) + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }) + + fromIdMock.mockReturnValueOnce({ id: 17, isDestroyed: () => true, send: rendererSend }) + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }) + + sendToTrustedUIRenderer('gh:workItemMutated', { number: 7 }, 17) + + expect(fromIdMock).toHaveBeenCalledTimes(2) + expect(rendererSend).not.toHaveBeenCalled() + }) + + it('routes to a reopened window without retaining the closed renderer', () => { + const oldRendererSend = vi.fn() + const newRendererSend = vi.fn() + fromIdMock.mockImplementation((id) => + id === 17 + ? { id, isDestroyed: () => false, send: oldRendererSend } + : { id, isDestroyed: () => false, send: newRendererSend } + ) + + setTrustedUIRendererWebContentsId(17) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 1 }) + + setTrustedUIRendererWebContentsId(42) + clearTrustedUIRendererWebContentsId(17) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 2 }) + + clearTrustedUIRendererWebContentsId(42) + sendToTrustedUIRenderer('gh:prRefreshEvent', { sequence: 3 }) + + expect(oldRendererSend).toHaveBeenCalledTimes(1) + expect(newRendererSend).toHaveBeenCalledTimes(1) + expect(newRendererSend).toHaveBeenCalledWith('gh:prRefreshEvent', { sequence: 2 }) + }) + it('routes native paste fallback to the requesting webContents only', () => { const paste = vi.fn() const pasteAndMatchStyle = vi.fn() diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index efe6e300f85..48c26308e3f 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain, type WebContents } from 'electron' +import { BrowserWindow, ipcMain, webContents, type WebContents } from 'electron' import type { Store } from '../persistence' import type { PersistedUIState } from '../../shared/types' import { isFeatureInteractionId } from '../../shared/feature-interactions' @@ -15,6 +15,24 @@ export function clearTrustedUIRendererWebContentsId(webContentsId: number): void } } +export function sendToTrustedUIRenderer( + channel: string, + payload: unknown, + excludedWebContentsId?: number +): void { + const rendererId = trustedUIRendererWebContentsId + if (rendererId == null || rendererId === excludedWebContentsId) { + return + } + const renderer = webContents.fromId(rendererId) + if (!renderer || renderer.isDestroyed()) { + return + } + // Why: app events belong to the registered UI renderer; enumerating every + // WebContents wakes retained browser guests that cannot consume the channel. + renderer.send(channel, payload) +} + export function registerUIHandlers(store: Store): void { // Why: UI view-state is shared between the desktop renderer and mobile (ui.set // RPC). Broadcast every change so the desktop re-hydrates when mobile (or diff --git a/src/preload/index.ts b/src/preload/index.ts index 2e960b9c461..4934cd31501 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1443,10 +1443,9 @@ const api = { sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), - // Why: every renderer subscribes to local mutation broadcasts so each - // window's work-item-details cache invalidates the affected entry. The - // event fires after a successful mutation in any window — see - // src/main/ipc/github.ts broadcastWorkItemMutated. + // Why: the app renderer owns the work-item-details cache. Main targets this + // bridge for non-origin mutations; origin callers already updated their + // cache optimistically — see src/main/ipc/github.ts. onWorkItemMutated: ( callback: (payload: { repoPath: string diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index ff76246a24c..fec1c28dab6 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1579,9 +1579,9 @@ function patchCachedWorkItemBody(cacheKey: string, body: string): void { } // Why: install once at module load — every dialog instance shares the cache, -// so a single subscription is enough. The preload bridge re-emits the -// main-process broadcast for every window, so each renderer invalidates its -// own cache when any window's mutation lands. We track the unsubscribe so +// so a single subscription is enough. Main targets the registered app renderer +// for non-origin mutations, and this listener invalidates the matching entry. +// We track the unsubscribe so // Vite HMR doesn't accumulate listeners across module reloads in dev. let workItemMutatedUnsub: (() => void) | undefined let workItemDetailsCacheEventUnsub: (() => void) | undefined diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 9114000a436..c6693d15376 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1621,9 +1621,9 @@ function patchCachedWorkItemBody(cacheKey: string, body: string): void { } // Why: install once at module load — every dialog instance shares the cache, -// so a single subscription is enough. The preload bridge re-emits the -// main-process broadcast for every window, so each renderer invalidates its -// own cache when any window's mutation lands. We track the unsubscribe so +// so a single subscription is enough. Main targets the registered app renderer +// for non-origin mutations, and this listener invalidates the matching entry. +// We track the unsubscribe so // Vite HMR doesn't accumulate listeners across module reloads in dev. let workItemMutatedUnsub: (() => void) | undefined let workItemDetailsCacheEventUnsub: (() => void) | undefined From 2a01b416384a7ecdfd10268a4cc4a71a80490f55 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:24 -0700 Subject: [PATCH 019/138] perf(main): make worktree path dedupe linear (#8177) --- config/tsconfig.tc.web.json | 1 + src/main/ipc/worktree-logic.ts | 61 +--------- src/main/ipc/worktree-path-comparison.ts | 108 ++++++++++++++++++ .../ipc/worktree-path-deduplication.test.ts | 59 ++++++++++ src/main/ipc/worktrees.ts | 16 +-- 5 files changed, 175 insertions(+), 70 deletions(-) create mode 100644 src/main/ipc/worktree-path-comparison.ts create mode 100644 src/main/ipc/worktree-path-deduplication.test.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 9f71e6e7900..f4fb2195223 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -10,6 +10,7 @@ "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", + "../src/main/ipc/worktree-path-comparison.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 503c830a97b..1f52f2fffe0 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,6 +1,6 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path' import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' -import { resolveRuntimePath } from '../../shared/cross-platform-path' +import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' import { getWslHome, parseWslPath } from '../wsl' @@ -10,6 +10,7 @@ type WorktreeBasePathRepo = Pick export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name' export { mergeWorktree } from './worktree-metadata-merge' +export { areWorktreePathsEqual } from './worktree-path-comparison' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -153,65 +154,13 @@ export function hasRepoWorktreeBasePath(repo: Pick): b return getRepoWorktreeBasePath(repo) !== undefined } -export function areWorktreePathsEqual( - leftPath: string, - rightPath: string, - platform = process.platform -): boolean { - if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { - // Why: local WSL projects run POSIX paths on a Windows desktop; comparing - // them with win32 rules can delete or dedupe the wrong runtime-owned path. - if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { - return false - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right - } - - if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { - const left = win32.normalize(win32.resolve(leftPath)) - const right = win32.normalize(win32.resolve(rightPath)) - // Why: `git worktree list` can report the same Windows path with different - // slash styles or drive-letter casing than the path we computed before - // creation. Orca must treat those as the same worktree or a successful - // create spuriously fails until the next full reload repopulates state. - return left.toLowerCase() === right.toLowerCase() - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return ( - /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//') - ) -} - -function looksLikePosixAbsolutePath(pathValue: string): boolean { - return pathValue.startsWith('/') && !pathValue.startsWith('//') -} - -function normalizePosixWorktreePathForComparison( - pathValue: string, - platform: NodeJS.Platform -): string { - const normalized = posix.normalize(posix.resolve(pathValue)) - if (platform !== 'darwin') { - return normalized - } - if (normalized === '/private/tmp') { - return '/tmp' - } - return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized -} - function getRuntimePathOps( repoPath: string, workspaceDir: string ): Pick { - return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix + return isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(workspaceDir) + ? win32 + : posix } function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string { diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts new file mode 100644 index 00000000000..38bb0aef7f9 --- /dev/null +++ b/src/main/ipc/worktree-path-comparison.ts @@ -0,0 +1,108 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export function areWorktreePathsEqual( + leftPath: string, + rightPath: string, + platform = process.platform +): boolean { + if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { + // Why: local WSL projects run POSIX paths on a Windows desktop; comparing + // them with win32 rules can delete or dedupe the wrong runtime-owned path. + if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { + return false + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right + } + + if ( + platform === 'win32' || + isWindowsAbsolutePathLike(leftPath) || + isWindowsAbsolutePathLike(rightPath) + ) { + const left = normalizeWindowsWorktreePathForComparison(leftPath) + const right = normalizeWindowsWorktreePathForComparison(rightPath) + // Why: Git can report the same Windows path with different slash styles or + // drive-letter casing; treating them as distinct creates duplicate worktrees. + return left === right + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right +} + +export function dedupeWorktreesByPath( + worktrees: readonly T[], + platform = process.platform +): T[] { + // Why: large Git/relay listings should normalize each path once while still + // preserving the first row under the cross-platform equality contract above. + const unique: T[] = [] + const posixAbsoluteKeys = new Set() + const windowsKeys = new Set() + const windowsPaths: string[] = [] + const relativePaths: string[] = [] + + for (const worktree of worktrees) { + const pathValue = worktree.path + if (looksLikePosixAbsolutePath(pathValue)) { + const key = normalizePosixWorktreePathForComparison(pathValue, platform) + if (posixAbsoluteKeys.has(key)) { + continue + } + posixAbsoluteKeys.add(key) + unique.push(worktree) + continue + } + + const windowsKey = normalizeWindowsWorktreePathForComparison(pathValue) + if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { + if ( + windowsKeys.has(windowsKey) || + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + windowsKeys.add(windowsKey) + windowsPaths.push(pathValue) + unique.push(worktree) + continue + } + + // Why: Git normally reports absolute paths. Retain pair-aware comparison + // only for malformed/legacy relative rows whose flavor depends on its peer. + if ( + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) || + windowsPaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + relativePaths.push(pathValue) + unique.push(worktree) + } + return unique +} + +function looksLikePosixAbsolutePath(pathValue: string): boolean { + return pathValue.startsWith('/') && !pathValue.startsWith('//') +} + +function normalizeWindowsWorktreePathForComparison(pathValue: string): string { + return win32.normalize(win32.resolve(pathValue)).toLowerCase() +} + +function normalizePosixWorktreePathForComparison( + pathValue: string, + platform: NodeJS.Platform +): string { + const normalized = posix.normalize(posix.resolve(pathValue)) + if (platform !== 'darwin') { + return normalized + } + if (normalized === '/private/tmp') { + return '/tmp' + } + return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized +} diff --git a/src/main/ipc/worktree-path-deduplication.test.ts b/src/main/ipc/worktree-path-deduplication.test.ts new file mode 100644 index 00000000000..70ebbbc4004 --- /dev/null +++ b/src/main/ipc/worktree-path-deduplication.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './worktree-path-comparison' + +function dedupeWithComparator( + worktrees: readonly T[], + platform: NodeJS.Platform +): T[] { + const unique: T[] = [] + for (const worktree of worktrees) { + if (!unique.some((existing) => areWorktreePathsEqual(existing.path, worktree.path, platform))) { + unique.push(worktree) + } + } + return unique +} + +describe('dedupeWorktreesByPath', () => { + it.each(['darwin', 'linux', 'win32'])( + 'preserves the comparator result on %s', + (platform) => { + const worktrees = [ + { id: 'posix-upper', path: '/home/dev/Repo' }, + { id: 'posix-lower', path: '/home/dev/repo' }, + { id: 'windows-first', path: 'C:\\Workspaces\\Feature' }, + { id: 'windows-duplicate', path: 'c:/workspaces/feature' }, + { id: 'unc-first', path: '\\\\Server\\Share\\Feature' }, + { id: 'unc-duplicate', path: '//server/share/feature' }, + { id: 'temp-private', path: '/private/tmp/orca/feature' }, + { id: 'temp-short', path: '/tmp/orca/feature' }, + { id: 'relative-first', path: 'workspaces/feature' }, + { id: 'relative-duplicate', path: 'workspaces/./feature' } + ] + + for (let offset = 0; offset < worktrees.length; offset += 1) { + const rotated = [...worktrees.slice(offset), ...worktrees.slice(0, offset)] + expect(dedupeWorktreesByPath(rotated, platform)).toEqual( + dedupeWithComparator(rotated, platform) + ) + const reversed = rotated.toReversed() + expect(dedupeWorktreesByPath(reversed, platform)).toEqual( + dedupeWithComparator(reversed, platform) + ) + } + } + ) + + it('reads each path once for a large unique list', () => { + let pathReads = 0 + const worktrees = Array.from({ length: 1_000 }, (_, index) => ({ + get path(): string { + pathReads += 1 + return `/workspaces/feature-${index}` + } + })) + + expect(dedupeWorktreesByPath(worktrees)).toHaveLength(1_000) + expect(pathReads).toBe(1_000) + }) +}) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 67468a78b06..92e626b2e3a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -72,6 +72,7 @@ import { isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from './worktree-logic' +import { dedupeWorktreesByPath } from './worktree-path-comparison' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { createLocalWorktree, @@ -208,19 +209,6 @@ async function closeLocalWatcherForRemoval(worktreePath: string): Promise }) } -function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeInfo[] { - const uniqueGitWorktrees: GitWorktreeInfo[] = [] - for (const gitWorktree of gitWorktrees) { - if ( - uniqueGitWorktrees.some((existing) => areWorktreePathsEqual(existing.path, gitWorktree.path)) - ) { - continue - } - uniqueGitWorktrees.push(gitWorktree) - } - return uniqueGitWorktrees -} - function getProjectHostSetupMetaUpdates( store: Store, repo: Repo, @@ -718,7 +706,7 @@ function buildDetectedGitWorktrees( const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - return dedupeGitWorktreesByPath(gitWorktrees).map((gitWorktree) => { + return dedupeWorktreesByPath(gitWorktrees).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) From b902f19b0df6c177988d006607634b9e2e29cb0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:00:31 -0700 Subject: [PATCH 020/138] fix(terminal): send CSI-u Shift+Enter to Droid on Windows (#7620) (#7668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): send CSI-u Shift+Enter to kitty TUIs (droid) on Windows (#7620) On Windows, Shift+Enter was always sent as the Alt+Enter byte ESC+CR (added in #2418 for Codex, which reads win32-input-mode and ignores CSI-u). droid speaks the kitty keyboard protocol, parses CSI-u directly, and treats ESC+CR as a plain Enter — so Shift+Enter SUBMITTED the message instead of inserting a newline. droid works in other terminals (Windows Terminal, Warp) because those honor win32-input-mode / kitty; Orca (xterm.js) withholds kitty from local Windows ConPTY panes and emits neither. Make the Windows Shift+Enter byte pane-aware: latch whether a pane's program advertised the kitty keyboard protocol (query CSI ? u, push CSI > .. u, or set CSI = .. u) and send CSI-u (\x1b[13;2u) to those panes, keeping the Codex-compatible ESC+CR for win32-input-mode-only TUIs. Non-Windows is unchanged (always CSI-u). Verified end-to-end against the real droid and Codex CLIs through the actual production functions: droid now newlines, Codex still newlines. * fix(terminal): route Windows Shift+Enter safely for Droid --------- Co-authored-by: Neil Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- config/reliability-gates.jsonc | 254 +++++- src/main/daemon/daemon-errors.ts | 1 - ...n-foreground-confirmation-protocol.test.ts | 9 + .../daemon-foreground-process-protocol.ts | 11 + src/main/daemon/daemon-pty-adapter.test.ts | 4 +- src/main/daemon/daemon-pty-adapter.ts | 27 +- src/main/daemon/daemon-pty-router.test.ts | 17 + src/main/daemon/daemon-pty-router.ts | 4 + src/main/daemon/daemon-server.test.ts | 45 + src/main/daemon/daemon-server.ts | 10 + .../degraded-daemon-pty-provider.test.ts | 14 + .../daemon/degraded-daemon-pty-provider.ts | 4 + ...subprocess-foreground-scan-cadence.test.ts | 5 +- src/main/daemon/pty-subprocess.test.ts | 112 ++- src/main/daemon/pty-subprocess.ts | 57 +- src/main/daemon/session.ts | 10 + .../daemon/terminal-host-create-contract.ts | 33 + src/main/daemon/terminal-host.ts | 52 +- src/main/daemon/types.ts | 27 +- src/main/ipc/pty.test.ts | 8 + src/main/ipc/pty.ts | 18 + .../agent-foreground-process.test.ts | 250 +++++- .../providers/agent-foreground-process.ts | 65 +- src/main/providers/local-pty-provider.test.ts | 23 +- src/main/providers/local-pty-provider.ts | 37 +- src/main/providers/types.ts | 7 + .../windows-agent-foreground-process.ts | 83 +- .../windows-conpty-process-membership.test.ts | 64 ++ .../windows-conpty-process-membership.ts | 74 ++ .../windows-foreground-process-rows.ts | 16 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../components/terminal-pane/TerminalPane.tsx | 1 + .../terminal-pane/keyboard-handlers.ts | 29 +- .../pane-foreground-agent-tracker.test.ts | 250 +++++- .../pane-foreground-agent-tracker.ts | 132 ++- .../terminal-pane/pty-connection.test.ts | 798 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 241 +++++- .../terminal-pane/pty-transport-types.ts | 1 + .../terminal-pane/pty-transport.test.ts | 26 + .../components/terminal-pane/pty-transport.ts | 8 +- .../terminal-pane-tab-detach.test.ts | 44 +- .../terminal-pane/terminal-pane-tab-detach.ts | 7 +- .../terminal-shortcut-policy.test.ts | 140 ++- .../terminal-pane/terminal-shortcut-policy.ts | 23 +- .../terminal-windows-shift-enter.test.ts | 88 ++ .../terminal-windows-shift-enter.ts | 43 + .../use-terminal-pane-lifecycle.ts | 22 + .../src/store/slices/pane-foreground-agent.ts | 3 + .../src/store/slices/store-cascades.test.ts | 1 + ...erminal-pane-detach-agent-identity.test.ts | 91 ++ src/renderer/src/store/slices/terminals.ts | 71 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/agent-process-recognition.test.ts | 7 + src/shared/process-table-snapshot.test.ts | 52 ++ src/shared/process-table-snapshot.ts | 78 +- src/shared/tui-agent-config.ts | 8 +- ...minal-foreground-confirmation.unit.test.ts | 130 +++ tests/e2e/terminal-shortcuts.spec.ts | 104 ++- 59 files changed, 3469 insertions(+), 276 deletions(-) create mode 100644 src/main/daemon/daemon-foreground-confirmation-protocol.test.ts create mode 100644 src/main/daemon/daemon-foreground-process-protocol.ts create mode 100644 src/main/daemon/terminal-host-create-contract.ts create mode 100644 src/main/providers/windows-conpty-process-membership.test.ts create mode 100644 src/main/providers/windows-conpty-process-membership.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts create mode 100644 src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts create mode 100644 tests/e2e/terminal-foreground-confirmation.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0c4df07a2a1..d43e149d449 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-09", + "updatedAt": "2026-07-10", "policy": { "maturityLevels": [ "experimental", @@ -1811,6 +1811,258 @@ ], "demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence." }, + { + "id": "terminal-input.windows-modified-enter-routing", + "title": "Windows modified Enter routes agent-compatible bytes to the active pane", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "main-provider-renderer-unit-and-windows-electron", + "surfaces": [ + "terminal input", + "Windows modified Enter", + "agent identity", + "split panes", + "SSH", + "WSL", + "remote runtime" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7620", + "https://github.com/stablyai/orca/pull/7668" + ], + "invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.", + "oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts" + ], + "testFiles": [ + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/daemon-server.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/windows-conpty-process-membership.test.ts", + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts", + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts", + "src/renderer/src/store/slices/store-cascades.test.ts", + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", + "src/shared/agent-process-recognition.test.ts", + "src/shared/process-table-snapshot.test.ts", + "tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "tests/e2e/terminal-shortcuts.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/process-table-snapshot.test.ts", + "assertions": [ + "a fresh request bypasses a same-tick TTL cache and any scan already in flight before the request", + "same-turn fresh requests and ordinary cache misses share one later scan while requests arriving after it starts queue behind it" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "successful root-present Windows enumeration and failed, blank, malformed, or root-missing enumeration remain distinct evidence states", + "an observed shell root with no children is authoritative while a snapshot omitting that root is unavailable", + "an attached Droid candidate is authorized only when exact ConPTY membership contains it, while a detached Start-Process descendant is rejected", + "a Windows process-table scan with no recognized agent performs zero ConPTY membership helper forks", + "fresh POSIX scan failure or a snapshot missing the PTY root is unavailable rather than shell proof", + "the compatibility process-name API still preserves its existing fallback contract for other consumers" + ] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "the fixed node-pty helper returns validated integer console membership containing the PTY root", + "root-only fallback, malformed/missing-root messages, spawn errors, and a silent helper timeout all fail closed", + "the bounded timeout kills the child helper exactly once" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "a delayed foreground confirmation is discarded after the owning PTY exits" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "new daemon sessions and warm reattach results carry the same minimal allowlisted launchAgent" + ] + }, + { + "file": "src/main/daemon/daemon-server.test.ts", + "assertions": [ + "daemon protocol v21 persists allowlisted launchAgent across reattach and drops unknown identity strings" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "Windows daemon foreground lookup can return PowerShell synchronously before async process-tree enrichment resolves the agent", + "the enriched agent result is served by a later foreground read", + "fresh confirmation awaits a post-request scan instead of serving cached PowerShell and returns null when inspection is unavailable" + ] + }, + { + "file": "tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "assertions": [ + "the real daemon handle and pane tracker keep restored Droid identity when confirmation resolves after the old cache window", + "one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts", + "assertions": [ + "Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u", + "SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability", + "non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts", + "assertions": [ + "only routing-trusted current process identity overrides launch evidence and confirmed shell foreground clears stale identity", + "a newer untrusted or neutral command generation blocks stale launch ownership until confirmation", + "only allowlisted fresh process or pane-scoped launch ownership selects Droid's CSI-u capability", + "forged hook or OSC-derived Droid status cannot change the Windows byte encoding", + "after split and original-pane close, a sole surviving sibling cannot inherit the original pane's Droid launch identity" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts", + "assertions": [ + "manual and restored local agent identity is published from bounded foreground-process reads", + "a delayed read cannot publish after the pane rebinds to a replacement PTY id", + "every new command publishes a routing-neutral generation before confirmation", + "typed agent text only widens the confirmation window and never becomes published or routable identity", + "unavailable typed-command confirmation stays on legacy routing through the three-read ladder and then fails closed", + "ordinary fast OSC 133;C-to-D command pairs publish shell without a fresh confirmation scan", + "command-start and visible launch recovery use at most three reads across the bounded 350 ms, 1.2 s, and 6 s ladder", + "null and rejected reads participate in the same bounded known-identity ladder", + "command-finished accepts one fresh recognized shell scan when agent evidence exists, while unavailable final evidence retires routing without claiming shell" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "after detach remount, pane-key launch/process identity forces a leaked nested OSC 133;D through foreground confirmation", + "a still-running Droid process republishes Droid instead of being overwritten with shell-foreground evidence", + "typing droid does not route CSI-u until fresh process evidence confirms Droid", + "null and rejected command-start reads never promote typed Droid text to process evidence", + "typed Droid input cannot replace live Antigravity process or pane-scoped launch identity inside another TUI", + "typed Droid input on SSH or remote-runtime panes never writes local process evidence", + "Droid routing survives a fresh command-finish scan that still sees Droid and retires after one fresh shell result", + "a D-to-C transition immediately routes legacy bytes while the new command generation is pending", + "an identityless visible-read versus OSC 133;D race cancels stale sampling and fails closed to shell", + "a manually typed Droid in a shell without OSC 133 gains routing only after accepted-input fresh confirmation", + "accepted no-OSC exit or interrupt input immediately revokes trusted Droid bytes and confirms the returned shell", + "unavailable command-finish evidence resolves deferred cleanup, returns legacy routing, and does not assert shell foreground", + "daemon-returned allowlisted launchAgent requests warm-reattach confirmation without restoring stale byte authority or the original args/env payload, while missing identity fails closed", + "an unknown persisted wrapper cannot spoof launch identity and confirmed shell state prevents repeat focus scans" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "allowlisted daemon launchAgent reaches PtyConnectResult on reattach", + "unknown launch identity is dropped at the renderer transport boundary" + ] + }, + { + "file": "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", + "assertions": [ + "pane-to-tab detach moves only the detached leaf's launch and process identity to its new pane key", + "pane-to-tab detach preserves the source PTY shell override across native PowerShell and WSL classifications", + "the source hook snapshot is retired rather than copied to a key the running PTY cannot emit", + "the detached Droid keeps CSI-u while the source sibling keeps its own legacy encoding" + ] + }, + { + "file": "src/shared/agent-process-recognition.test.ts", + "assertions": [ + "Droid's process name resolves to the allowlisted Droid agent identity without substring matching" + ] + }, + { + "file": "tests/e2e/terminal-shortcuts.spec.ts", + "assertions": [ + "on Windows the focused terminal sends CSI-u bytes for a Droid pane and Esc+CR bytes after switching that same pane to Antigravity identity", + "the byte oracle observes the real renderer-to-main PTY write path rather than terminal DOM state" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts", + "result": "passed", + "durationSeconds": 8.71, + "summary": "Twenty-two focused test files passed (1206 tests) on current main, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence." + } + ], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "focused renderer units plus the Windows Electron terminal-shortcut spec" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic units are stable locally; the Windows-only Electron byte path needs CI or nightly soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Reverting to the previous unconditional Windows Esc+CR branch fails the Droid byte assertion, while resolving launch metadata from any key other than the active pane fails the split-survivor isolation assertion. Saved intentional-break artifacts and live Windows history are still missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added." + }, + "promotionCriteria": [ + "Run the exact PTY-byte Electron oracle on Windows without silent platform skips.", + "Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.", + "Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage." + ], + "knownGaps": [ + "No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.", + "Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.", + "The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.", + "If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.", + "Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.", + "No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.", + "A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs." + ], + "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index 99d19818a3c..8077add319a 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -1,6 +1,5 @@ // Error classes shared across the daemon protocol boundary (client, server, // host). Split from types.ts, which is capped for wire-shape declarations. - export class TerminalAttachCanceledError extends Error { constructor(sessionId: string) { super(`Attach canceled for session ${sessionId}`) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts new file mode 100644 index 00000000000..4fbb3047d6a --- /dev/null +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' + +describe('foreground-confirmation daemon protocol', () => { + it('rejects daemons from before the fresh-confirmation RPC', () => { + expect(PROTOCOL_VERSION).toBeGreaterThan(19) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) + }) +}) diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts new file mode 100644 index 00000000000..01b050c58d3 --- /dev/null +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -0,0 +1,11 @@ +export type GetForegroundProcessRequest = { + id: string + type: 'getForegroundProcess' + payload: { + sessionId: string + } +} + +export type ConfirmForegroundProcessRequest = Omit & { + type: 'confirmForegroundProcess' +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 310ea2aa955..06a77789f4b 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -489,9 +489,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('spawn with sessionId (reattach)', () => { it('returns full snapshot and isReattach when reattaching', async () => { const sessionId = 'reattach-test-session' - const first = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const first = await adapter.spawn({ cols: 80, rows: 24, sessionId, launchAgent: 'droid' }) expect(first.id).toBe(sessionId) expect(first.isReattach).toBeUndefined() + expect(first.launchAgent).toBe('droid') // Write data so the headless emulator captures it lastSubprocess._simulateData('hello from shell\r\n') @@ -501,6 +502,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const second = await adapter.spawn({ cols: 80, rows: 24, sessionId }) expect(second.id).toBe(sessionId) expect(second.isReattach).toBe(true) + expect(second.launchAgent).toBe('droid') expect(second.snapshot).toBeDefined() expect(second.snapshot).toContain('hello from shell') }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 3535b410404..e0d5ec598ca 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -237,6 +237,7 @@ export class DaemonPtyAdapter implements IPtyProvider { envToDelete: opts.envToDelete, command: opts.command, startupCommandDelivery: opts.startupCommandDelivery, + launchAgent: opts.launchAgent, // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or // PowerShell as a fallback — regardless of which shell the renderer // asked for in the "+" menu or persisted as the default. Forwarding @@ -252,6 +253,8 @@ export class DaemonPtyAdapter implements IPtyProvider { let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + const launchIdentity = (): { launchAgent?: NonNullable } => + result.launchAgent ? { launchAgent: result.launchAgent } : {} if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -278,6 +281,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), coldRestore: cachedRestore, ...(!result.isNew ? { isReattach: true } : {}) } @@ -342,11 +346,12 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), coldRestore, ...(!result.isNew ? { isReattach: true } : {}) } } - return { id: sessionId, pid } + return { id: sessionId, pid, ...launchIdentity() } } if (this.historyManager && result.isNew) { @@ -380,7 +385,12 @@ export class DaemonPtyAdapter implements IPtyProvider { const isReattach = !result.isNew if (!isReattach || !result.snapshot) { - return { id: sessionId, pid, ...(isReattach ? { isReattach: true } : {}) } + return { + id: sessionId, + pid, + ...launchIdentity(), + ...(isReattach ? { isReattach: true } : {}) + } } const isAltScreen = result.snapshot.modes.alternateScreen @@ -396,6 +406,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, pid, + ...launchIdentity(), snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, @@ -658,6 +669,18 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + async confirmForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'confirmForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + async serialize(ids: string[]): Promise { const sessions: Record = {} for (const id of ids) { diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index a8c2b2232d8..eb8acb86488 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -63,6 +63,7 @@ function createAdapter( acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), getDefaultShell: vi.fn(async () => '/bin/zsh'), @@ -121,6 +122,22 @@ function createAdapter( } describe('DaemonPtyRouter', () => { + it('routes fresh foreground confirmation to the session-owning daemon', async () => { + const current = createAdapter('current', ['current-session']) + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await expect(router.confirmForegroundProcess('legacy-session')).resolves.toBe( + 'legacy-confirmed' + ) + await expect(router.confirmForegroundProcess('current-session')).resolves.toBe( + 'current-confirmed' + ) + expect(legacy.confirmForegroundProcess).toHaveBeenCalledWith('legacy-session') + expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session') + }) + it('routes existing legacy sessions to their old daemon and new sessions to current daemon', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index ed43c663325..18bf0f926b5 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -146,6 +146,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).getForegroundProcess(id) } + async confirmForegroundProcess(id: string): Promise { + return this.adapterFor(id).confirmForegroundProcess(id) + } + async serialize(ids: string[]): Promise { return this.current.serialize(ids) } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 7de928d7543..3760ee89de2 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -10,6 +10,8 @@ import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' import { getDaemonSocketPath } from './daemon-spawner' +const confirmForegroundProcessMock = vi.fn(async () => 'droid') + function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-server-test-')) } @@ -23,6 +25,7 @@ function createMockSubprocess(): SubprocessHandle & { return { pid: 55555, getForegroundProcess: vi.fn(() => null), + confirmForegroundProcess: confirmForegroundProcessMock, write: vi.fn(), resize: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), @@ -65,6 +68,7 @@ describe('DaemonServer', () => { let client: DaemonClient beforeEach(() => { + confirmForegroundProcessMock.mockClear() dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -174,6 +178,34 @@ describe('DaemonServer', () => { }) }) + it('persists only an allowlisted launch identity across reattach', async () => { + await startServer() + const c = await connectClient() + + const first = await c.request('createOrAttach', { + sessionId: 'agent-session', + cols: 80, + rows: 24, + launchAgent: 'droid' + }) + expect(first).toMatchObject({ isNew: true, launchAgent: 'droid' }) + + const second = await c.request('createOrAttach', { + sessionId: 'agent-session', + cols: 80, + rows: 24 + }) + expect(second).toMatchObject({ isNew: false, launchAgent: 'droid' }) + + const unknown = await c.request('createOrAttach', { + sessionId: 'unknown-agent-session', + cols: 80, + rows: 24, + launchAgent: 'not-an-agent' + } as never) + expect(unknown).not.toHaveProperty('launchAgent') + }) + it('handles listSessions', async () => { await startServer() const c = await connectClient() @@ -279,6 +311,19 @@ describe('DaemonServer', () => { expect(result.cwd === null || typeof result.cwd === 'string').toBe(true) }) + it('awaits fresh foreground confirmation', async () => { + await startServer() + const c = await connectClient() + await c.request('createOrAttach', { sessionId: 'test-session', cols: 80, rows: 24 }) + + await expect( + c.request<{ foregroundProcess: string | null }>('confirmForegroundProcess', { + sessionId: 'test-session' + }) + ).resolves.toEqual({ foregroundProcess: 'droid' }) + expect(confirmForegroundProcessMock).toHaveBeenCalledTimes(1) + }) + it('returns error for unknown session operations', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7c604341c2c..fe87022e5c5 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,6 +22,7 @@ import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-syst import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' +import { isTuiAgent } from '../../shared/tui-agent-config' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -341,6 +342,9 @@ export class DaemonServer { envToDelete: p.envToDelete, command: p.command, startupCommandDelivery: p.startupCommandDelivery, + // Why: daemon RPC payloads are untrusted JSON. Persist only the + // allowlisted enum used for byte routing, never arbitrary identity. + ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), shellOverride: p.shellOverride, terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, @@ -406,6 +410,7 @@ export class DaemonServer { snapshot: result.snapshot, pid: result.pid, shellState: result.shellState, + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } @@ -512,6 +517,11 @@ export class DaemonServer { case 'getForegroundProcess': return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + case 'confirmForegroundProcess': + return { + foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) + } + case 'clearScrollback': this.host.clearScrollback(request.payload.sessionId) return {} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index fb0b5087f15..188173816db 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -37,6 +37,7 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), @@ -108,6 +109,19 @@ function createDaemonAdapter( } describe('DegradedDaemonPtyProvider', () => { + it('routes fresh foreground confirmation to the session owner', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.confirmForegroundProcess('daemon-session')).resolves.toBe( + 'daemon-confirmed' + ) + await expect(provider.confirmForegroundProcess(fresh.id)).resolves.toBe('fallback-confirmed') + }) + it('routes discovered daemon sessions to the daemon and fresh PTYs to the fallback', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 4e818c9b6c0..572cb0aca37 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -160,6 +160,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.providerFor(id).getForegroundProcess(id) } + async confirmForegroundProcess(id: string): Promise { + return this.providerFor(id).confirmForegroundProcess?.(id) ?? null + } + async serialize(ids: string[]): Promise { return this.fallback.serialize(ids) } diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts index b198f3e7475..937dca27bc4 100644 --- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -40,7 +40,10 @@ vi.mock('../providers/windows-powershell-executable', () => ({ })) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ + available: true, + processName: await resolveAgentForegroundProcessMock(...args) + }) })) import { createPtySubprocess } from './pty-subprocess' diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 9ff69f89ab3..908ef9a68b2 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -56,7 +56,12 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { }) vi.mock('../providers/agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveAgentForegroundProcessMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } })) import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' @@ -514,6 +519,111 @@ describe('createPtySubprocess', () => { } }) + it('preserves the ordinary fallback when Windows process enumeration is unavailable', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: false, + processName: 'powershell.exe' + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + + expect(handle.getForegroundProcess()).toBe('powershell.exe') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(handle.getForegroundProcess()).toBe('powershell.exe') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('awaits a fresh delayed Windows scan instead of serving the shell fallback', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + let resolveFresh!: (processName: string) => void + resolveAgentForegroundProcessMock.mockReturnValue( + new Promise((resolve) => { + resolveFresh = resolve + }) + ) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + const confirmation = handle.confirmForegroundProcess!() + let settled = false + void confirmation.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveFresh('droid') + await expect(confirmation).resolves.toBe('droid') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledExactlyOnceWith( + proc.pid, + 'powershell.exe', + expect.objectContaining({ fresh: true, forceProcessScan: true }) + ) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('returns null when a fresh Windows confirmation scan is unavailable', async () => { + const proc = mockPtyProcess() + proc.process = 'powershell.exe' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: false, + processName: 'powershell.exe' + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + await expect(handle.confirmForegroundProcess!()).resolves.toBeNull() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('returns null when a recognized Windows fallback disappears during confirmation', async () => { + const proc = mockPtyProcess() + proc.process = 'droid' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: null + }) + + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + await expect(handle.confirmForegroundProcess!()).resolves.toBeNull() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('serves Unix agent foreground when node-pty reports an agent child process', async () => { const proc = mockPtyProcess() proc.process = 'uv' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 119b3fb9430..5d36ebe9d69 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -41,7 +41,8 @@ import { } from '../pty/powerlevel10k-wizard-env' import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process' +import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process' +import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership' import { isAgentForegroundWrapperProcess, recognizeAgentProcess, @@ -940,10 +941,10 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: daemon foreground reads are sync and run on the IPC hot path. // Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.) // in the background and serve them from a short cache on later reads. - void resolveAgentForegroundProcess(proc.pid, fallbackProcess, { + void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { contextPaths: agentForegroundContextPaths }) - .then((processName) => { + .then(({ processName }) => { if (dead) { return } @@ -1045,6 +1046,56 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return null } }, + confirmForegroundProcess: async () => { + if (dead || !proc.pid) { + return null + } + try { + const fallbackProcess = getFallbackForegroundProcess() + if ( + !fallbackProcess || + (recognizeAgentProcess(fallbackProcess) && process.platform !== 'win32') || + (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) + ) { + return fallbackProcess + } + // Why: cached/in-flight scans may predate the OSC command boundary. + // Confirmation requires one shared process snapshot started afterward. + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths: agentForegroundContextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + if (dead || !resolution.available) { + return null + } + const recognized = recognizeAgentProcess(resolution.processName) + if (recognized) { + cachedAgentForeground = { + processName: recognized.processName, + refreshedAt: Date.now() + } + startupAgentForeground = null + return recognized.processName + } + // Why: a successful post-boundary scan that resolves no agent is the + // authority that retires stale cached/startup identity. + cachedAgentForeground = null + startupAgentForeground = null + return resolution.processName + } catch { + return null + } + }, write: (data) => { if (dead) { return diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 80987b5a1bb..06404fd9246 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -9,6 +9,7 @@ import { type ShellReadyScanState } from '../shell-ready-marker-scanner' import { isPowerShellProcess } from '../../shared/shell-process-detection' +import type { TuiAgent } from '../../shared/types' import type { PendingOutputRecord, SessionState, @@ -42,6 +43,8 @@ export type SubprocessHandle = { /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(): string | null + /** Await process-table evidence captured after this confirmation request. */ + confirmForegroundProcess?(): Promise /** True when shell launch args already delivered the startup command, so the * terminal host must skip its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean @@ -72,6 +75,7 @@ export type SessionOptions = { cols: number rows: number terminalHandle?: string + launchAgent?: TuiAgent subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -94,6 +98,7 @@ type AttachedClient = { export class Session { readonly sessionId: string readonly terminalHandle: string | null + readonly launchAgent: TuiAgent | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -120,6 +125,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null + this.launchAgent = opts.launchAgent ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) @@ -360,6 +366,10 @@ export class Session { return this.subprocess.getForegroundProcess() } + async confirmForegroundProcess(): Promise { + return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() + } + clearScrollback(): void { if (this._disposed) { return diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts new file mode 100644 index 00000000000..a60570ac5fe --- /dev/null +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -0,0 +1,33 @@ +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/types' +import type { ShellReadyState, TerminalSnapshot } from './types' + +export type CreateOrAttachOptions = { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + command?: string + startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent + /** Explicit shell the renderer asked for, forwarded to the subprocess. */ + shellOverride?: string + terminalWindowsWslDistro?: string | null + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + shellReadySupported?: boolean + shellReadyTimeoutMs?: number + historySeed?: string + streamClient: { onData: (data: string) => void; onExit: (code: number) => void } +} + +export type CreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState + historySeeded?: boolean + launchAgent?: TuiAgent + attachToken: symbol +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 644356a6c4f..203719d354e 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -3,47 +3,14 @@ import { normalizePtySize } from './daemon-pty-size' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' -import type { - SessionInfo, - TakePendingOutputResult, - TerminalSnapshot, - ShellReadyState -} from './types' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import { SessionNotFoundError } from './types' +import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' + +export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' const DEFAULT_MAX_TOMBSTONES = 1000 -export type CreateOrAttachOptions = { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - command?: string - startupCommandDelivery?: StartupCommandDelivery - /** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL - * terminal" from the "+" menu). Forwarded to the subprocess spawner so the - * daemon path honors per-tab shell selection the same way LocalPtyProvider - * does. */ - shellOverride?: string - terminalWindowsWslDistro?: string | null - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - shellReadySupported?: boolean - shellReadyTimeoutMs?: number - historySeed?: string - streamClient: { onData: (data: string) => void; onExit: (code: number) => void } -} - -export type CreateOrAttachResult = { - isNew: boolean - snapshot: TerminalSnapshot | null - pid: number | null - shellState: ShellReadyState - historySeeded?: boolean - attachToken: symbol -} - export type TerminalHostOptions = { spawnSubprocess: (opts: { sessionId: string @@ -107,6 +74,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, + ...(existing.launchAgent ? { launchAgent: existing.launchAgent } : {}), ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), attachToken: token } @@ -141,6 +109,7 @@ export class TerminalHost { cols: size.cols, rows: size.rows, terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + launchAgent: opts.launchAgent, subprocess, shellReadySupported: opts.shellReadySupported ?? false, historySeed: opts.historySeed, @@ -184,6 +153,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, + ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), attachToken: token } @@ -274,6 +244,14 @@ export class TerminalHost { return session.getForegroundProcess() } + async confirmForegroundProcess(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return null + } + return session.confirmForegroundProcess() + } + clearScrollback(sessionId: string): void { this.getAliveSession(sessionId).clearScrollback() } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 663166e6fca..550b211aa74 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,15 +1,24 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { + ConfirmForegroundProcessRequest, + GetForegroundProcessRequest +} from './daemon-foreground-process-protocol' + +export type { + ConfirmForegroundProcessRequest, + GetForegroundProcessRequest +} from './daemon-foreground-process-protocol' // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' - +import type { TuiAgent } from '../../shared/types' // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 20 +export const PROTOCOL_VERSION = 21 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -98,6 +107,7 @@ export type CreateOrAttachRequest = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery + launchAgent?: TuiAgent /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). * The daemon forwards this to its subprocess spawner so each tab honors * the shell picked in the "+" menu or the persisted default-shell setting, @@ -216,14 +226,6 @@ export type GetCwdRequest = { } } -export type GetForegroundProcessRequest = { - id: string - type: 'getForegroundProcess' - payload: { - sessionId: string - } -} - export type ClearScrollbackRequest = { id: string type: 'clearScrollback' @@ -330,6 +332,7 @@ export type DaemonRequest = | DetachRequest | GetCwdRequest | GetForegroundProcessRequest + | ConfirmForegroundProcessRequest | ClearScrollbackRequest | ShutdownRequest | PingRequest @@ -361,8 +364,8 @@ export type CreateOrAttachResult = { pid: number | null shellState: ShellReadyState historySeeded?: boolean + launchAgent?: TuiAgent } - export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 66a0c1c0750..2b405685c45 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -555,6 +555,7 @@ describe('registerPtyHandlers', () => { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), onData: vi.fn(() => () => {}), @@ -596,6 +597,7 @@ describe('registerPtyHandlers', () => { acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => { @@ -3973,6 +3975,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), getDefaultShell: vi.fn(), @@ -4034,6 +4037,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), + confirmForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), getDefaultShell: vi.fn(), @@ -4050,8 +4054,12 @@ describe('registerPtyHandlers', () => { await expect( handlers.get('pty:getForegroundProcess')!(null, { id: 'remote-pty' }) ).resolves.toBeNull() + await expect( + handlers.get('pty:confirmForegroundProcess')!(null, { id: 'remote-pty' }) + ).resolves.toBeNull() expect(provider.hasChildProcesses).not.toHaveBeenCalled() expect(provider.getForegroundProcess).not.toHaveBeenCalled() + expect(provider.confirmForegroundProcess).not.toHaveBeenCalled() }) // Why: regression for the Claude-Code split-pane garbled-render desync. resize diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0ca456f7607..893a65e4c65 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -34,6 +34,7 @@ import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { isTuiAgent } from '../../shared/tui-agent-config' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { @@ -1480,6 +1481,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:hasPty') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:confirmForegroundProcess') ipcMain.removeHandler('pty:getCwd') ipcMain.removeHandler('pty:getSize') ipcMain.removeHandler('pty:declarePendingPaneSerializer') @@ -3916,6 +3918,9 @@ export function registerPtyHandlers( if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } + if (isTuiAgent(args.launchAgent)) { + spawnOptions.launchAgent = args.launchAgent + } if (args.worktreeId !== undefined) { spawnOptions.worktreeId = args.worktreeId } @@ -4957,6 +4962,19 @@ export function registerPtyHandlers( } ) + ipcMain.handle( + 'pty:confirmForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (!hasPtyProviderForInspection(args.id)) { + return null + } + const provider = getProviderForPty(args.id) + // Why: falling back to the cached foreground API would turn stale + // process identity into shell/agent authority at a command boundary. + return provider.confirmForegroundProcess?.(args.id) ?? null + } + ) + // Why: renderer needs the live shell cwd when the user presses Cmd+D so // the new split pane inherits the source pane's cwd instead of the // worktree root. Routed through getProviderForPty so local and SSH PTYs diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index c21d501b49a..a93735a0287 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -9,7 +9,10 @@ vi.mock('child_process', () => ({ })) import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +import { + resolveAgentForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' // Why: the module wraps execFile with promisify, so the mock must honor the @@ -106,6 +109,28 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') }) + it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { + mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + }) + + it('treats a failed fresh POSIX scan as unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') + }) + it('does not report Claude print-mode hook descendants as foreground agents', async () => { mockPs( [ @@ -270,6 +295,117 @@ describe('resolveAgentForegroundProcess', () => { ) }) + it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('enumeration unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + + it.each([ + ['blank', ' \r\n'], + ['unparseable', 'wmic returned no structured process values'] + ])('treats successful but %s WMIC output as unavailable', async (_label, wmicOutput) => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation((cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + if (cmd === 'powershell.exe') { + callback(new Error('powershell unavailable'), { stdout: '', stderr: '' }) + return + } + callback(null, { stdout: wmicOutput, stderr: '' }) + }) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + + it('treats an observed Windows shell with no children as authoritative', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]), + stderr: '' + }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('does not restore a recognized fallback that disappeared before confirmation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'droid', { + fresh: true, + forceProcessScan: true + }) + ).resolves.toEqual({ available: true, processName: null }) + }) + + it('treats a Windows snapshot missing the requested shell as unavailable', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: windowsProcessJsonRows([ + { + CommandLine: 'unrelated.exe', + Name: 'unrelated.exe', + ParentProcessId: 99, + ProcessId: 200 + } + ]), + stderr: '' + }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe') + ).resolves.toEqual({ available: false, processName: 'powershell.exe' }) + await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe( + 'powershell.exe' + ) + }) + it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) execFileMock.mockImplementation( @@ -334,6 +470,39 @@ describe('resolveAgentForegroundProcess', () => { ) }) + it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + }, + { + CommandLine: 'agy', + Name: 'agy.exe', + ParentProcessId: 100, + ProcessId: 102 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toEqual({ available: true, processName: 'droid' }) + }) + it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) execFileMock.mockImplementation( @@ -494,4 +663,83 @@ describe('resolveAgentForegroundProcess', () => { await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe') expect(execFileMock).not.toHaveBeenCalled() }) + + it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + } + ]) + ) + const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101, 999])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'droid' }) + expect(readWindowsConptyProcessIds).toHaveBeenCalledTimes(1) + }) + + it('excludes a detached Windows Droid descendant from byte authority', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: 'droid', + Name: 'droid.exe', + ParentProcessId: 100, + ProcessId: 101 + } + ]) + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds: async () => new Set([100, 999]) + }) + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mockPs( + windowsProcessJsonRows([ + { + CommandLine: 'powershell.exe', + Name: 'powershell.exe', + ParentProcessId: 99, + ProcessId: 100 + } + ]) + ) + const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 999])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConptyProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'powershell.exe' }) + expect(readWindowsConptyProcessIds).not.toHaveBeenCalled() + }) }) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index d60582964bd..6224b804953 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,13 +1,22 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot' import { - resolveWindowsAgentForegroundProcess, + getFreshProcessTableSnapshot, + getProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import { + resolveWindowsAgentForegroundProcessWithAvailability, shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' +export type AgentForegroundProcessResolution = { + available: boolean + processName: string | null +} + function collectDescendants( rows: Row[], rootPid: number @@ -43,29 +52,57 @@ export async function resolveAgentForegroundProcess( fallbackProcess: string | null, options: AgentForegroundResolutionOptions = {} ): Promise { + return (await resolveAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, options)) + .processName +} + +export async function resolveAgentForegroundProcessWithAvailability( + shellPid: number | null | undefined, + fallbackProcess: string | null, + options: AgentForegroundResolutionOptions = {} +): Promise { if (!shellPid) { - return fallbackProcess + return { available: false, processName: fallbackProcess } } if (process.platform === 'win32') { - if (!fallbackProcess || !shouldInspectWindowsAgentForeground(fallbackProcess)) { - return fallbackProcess + if ( + !fallbackProcess || + (!shouldInspectWindowsAgentForeground(fallbackProcess) && !options.forceProcessScan) + ) { + return { available: true, processName: fallbackProcess } } - return ( - (await resolveWindowsAgentForegroundProcess(shellPid, fallbackProcess, options)) ?? - fallbackProcess + const resolution = await resolveWindowsAgentForegroundProcessWithAvailability( + shellPid, + fallbackProcess, + options ) + return { + available: resolution.available, + // Why: a forced confirmation scan that no longer sees the recognized + // fallback is authoritative evidence that the agent exited meanwhile. + processName: + resolution.processName ?? + (options.forceProcessScan && recognizeAgentProcessFromCommandLine(fallbackProcess) + ? null + : fallbackProcess) + } } try { - const rows = await getProcessTableSnapshot() - return resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + const rows = options.fresh + ? await getFreshProcessTableSnapshot() + : await getProcessTableSnapshot() + if (options.fresh && !rows.some((row) => row.pid === shellPid)) { + return { available: false, processName: fallbackProcess } + } + return { + available: true, + processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + } } catch { - // Fall through to node-pty's process name. Foreground process inspection is - // best-effort because terminal identity should never break PTY operation. + return { available: !options.fresh, processName: fallbackProcess } } - - return fallbackProcess } function resolveAgentForegroundProcessFromPs( diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 1448f59d946..57748417cac 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -58,7 +58,10 @@ vi.mock('./windows-powershell-executable', () => ({ })) vi.mock('./agent-foreground-process', () => ({ - resolveAgentForegroundProcess: resolveAgentForegroundProcessMock + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => ({ + available: true, + processName: await resolveAgentForegroundProcessMock(...args) + }) })) vi.mock('../wsl', () => ({ @@ -1039,6 +1042,24 @@ describe('LocalPtyProvider', () => { }) }) + describe('confirmForegroundProcess', () => { + it('drops a delayed result after the PTY exits', async () => { + let resolveScan!: (processName: string) => void + resolveAgentForegroundProcessMock.mockReturnValue( + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + const confirmation = provider.confirmForegroundProcess(id) + exitCb?.({ exitCode: 0 }) + resolveScan('droid') + + await expect(confirmation).resolves.toBeNull() + }) + }) + describe('event listeners', () => { it('notifies data listeners when PTY produces output', async () => { const dataHandler = vi.fn() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 941fe062997..806dfdd5d24 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -51,9 +51,10 @@ import { resolveWindowsGitBashShellPath } from '../git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' @@ -971,13 +972,45 @@ export class LocalPtyProvider implements IPtyProvider { return null } try { - return await resolveAgentForegroundProcess( + const resolution = await resolveAgentForegroundProcessWithAvailability( proc.pid, resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), { contextPaths: ptyAgentForegroundContextPaths.get(id) } ) + return resolution.processName + } catch { + return null + } + } + + async confirmForegroundProcess(id: string): Promise { + const proc = ptyProcesses.get(id) + if (!proc) { + return null + } + try { + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), + { + contextPaths: ptyAgentForegroundContextPaths.get(id), + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + // Why: a fresh scan can outlive this PTY id; never publish identity from + // an exited process or a replacement session that reused the same id. + if (ptyProcesses.get(id) !== proc) { + return null + } + return resolution.available ? resolution.processName : null } catch { return null } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 24bd305bfbc..d5873c948ea 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -12,6 +12,7 @@ import type { GitStagingArea, GitUpstreamStatus, GitWorktreeInfo, + TuiAgent, RemoveWorktreeResult, SearchOptions, SearchResult @@ -62,6 +63,8 @@ export type PtySpawnOptions = { command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery + /** Minimal allowlisted launch ownership preserved by daemon reattach. */ + launchAgent?: TuiAgent /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string @@ -105,6 +108,8 @@ export type PtySpawnResult = { * local providers read it from node-pty. Null when the underlying * provider could not publish a pid (e.g., race during spawn). */ pid?: number | null + /** Minimal allowlisted launch ownership returned by daemon reattach. */ + launchAgent?: TuiAgent /** ANSI snapshot of the terminal screen, present when reattaching to an * existing daemon session. Write this to xterm.js to restore visual state. */ snapshot?: string @@ -204,6 +209,8 @@ export type IPtyProvider = { acknowledgeDataEvent(id: string, charCount: number): void hasChildProcesses(id: string): Promise getForegroundProcess(id: string): Promise + /** Strong process evidence captured after the caller's command boundary. */ + confirmForegroundProcess?: (id: string) => Promise serialize(ids: string[]): Promise revive(state: string): Promise listProcesses(): Promise diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 39447432a91..556d6763e62 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -12,6 +12,17 @@ import { export type AgentForegroundResolutionOptions = { contextPaths?: readonly string[] + /** Require a Windows process-table scan started after this request. */ + fresh?: boolean + /** Force confirmation scans even when node-pty reports a recognized name. */ + forceProcessScan?: boolean + /** Lazily proves which global descendants still belong to this ConPTY. */ + readWindowsConptyProcessIds?: () => Promise | null> +} + +export type WindowsAgentForegroundResolution = { + available: boolean + processName: string | null } export function shouldInspectWindowsAgentForeground(fallbackProcess: string): boolean { @@ -23,21 +34,79 @@ export async function resolveWindowsAgentForegroundProcess( fallbackProcess: string, options: AgentForegroundResolutionOptions ): Promise { - const candidates = await queryWindowsProcessDescendants(shellPid) + return ( + await resolveWindowsAgentForegroundProcessWithAvailability(shellPid, fallbackProcess, options) + ).processName +} + +export async function resolveWindowsAgentForegroundProcessWithAvailability( + shellPid: number, + fallbackProcess: string, + options: AgentForegroundResolutionOptions +): Promise { + const candidates = await queryWindowsProcessDescendants( + shellPid, + options.fresh === true ? { fresh: true } : {} + ) if (!candidates) { - return null + return { available: false, processName: null } } + // Resolve membership before applying the global ambiguity rule. A detached + // agent can otherwise make an attached Droid look ambiguous and suppress + // the only identity that is actually able to receive this PTY's input. + const hasRecognizedCandidate = windowsCandidatesContainRecognizedAgent( + candidates, + fallbackProcess, + options.contextPaths + ) + let filteredCandidates = candidates + if (hasRecognizedCandidate && options.readWindowsConptyProcessIds) { + const conptyProcessIds = await options.readWindowsConptyProcessIds() + if (!conptyProcessIds) { + return { available: false, processName: null } + } + filteredCandidates = candidates.filter((candidate) => conptyProcessIds.has(candidate.pid)) + } + return { + available: true, + processName: resolveWindowsProcessName( + filteredCandidates, + fallbackProcess, + options.contextPaths + ) + } +} + +function windowsCandidatesContainRecognizedAgent( + candidates: readonly WindowsProcessCandidate[], + fallbackProcess: string, + contextPaths: readonly string[] | undefined +): boolean { if (isShellProcess(fallbackProcess)) { - return resolveShellForegroundProcessFromWindowsCandidates(candidates, options.contextPaths) + return createRecognizedWindowsProcessCandidates(candidates, contextPaths).length > 0 + } + return candidates + .filter((candidate) => windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess)) + .some( + (candidate) => + recognizeAgentProcessFromCommandLine(candidate.command) !== null || + recognizeAgentProcessFromCommandLine(candidate.name) !== null + ) +} + +function resolveWindowsProcessName( + candidates: readonly WindowsProcessCandidate[], + fallbackProcess: string, + contextPaths: readonly string[] | undefined +): string | null { + if (isShellProcess(fallbackProcess)) { + return resolveShellForegroundProcessFromWindowsCandidates(candidates, contextPaths) } const wrapperCandidates = candidates.filter((candidate) => windowsCandidateMatchesFallbackWrapper(candidate, fallbackProcess) ) if (wrapperCandidates.length !== 1) { - return resolveWrapperForegroundProcessFromWindowsCandidates( - wrapperCandidates, - options.contextPaths - ) + return resolveWrapperForegroundProcessFromWindowsCandidates(wrapperCandidates, contextPaths) } const [candidate] = wrapperCandidates const recognized = diff --git a/src/main/providers/windows-conpty-process-membership.test.ts b/src/main/providers/windows-conpty-process-membership.test.ts new file mode 100644 index 00000000000..e9e2ef09fdd --- /dev/null +++ b/src/main/providers/windows-conpty-process-membership.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' + +function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { + const child = new EventEmitter() as EventEmitter & { kill: ReturnType } + child.kill = vi.fn() + const forkProcess = vi.fn(() => { + queueMicrotask(() => { + if (event === 'message') { + child.emit('message', { consoleProcessList: value }) + } else if (event === 'error') { + child.emit('error', new Error('spawn failed')) + } + }) + return child + }) + return { child, forkProcess: forkProcess as never } +} + +describe('readWindowsConptyProcessIds', () => { + it('returns exact console membership from the fixed node-pty helper', async () => { + const { forkProcess } = forkWith('message', [101, 202, 303]) + + await expect( + readWindowsConptyProcessIds(101, { + forkProcess, + resolveAgentPath: () => '/fixed/node-pty/conpty_console_list_agent.js' + }) + ).resolves.toEqual(new Set([101, 202, 303])) + expect(forkProcess).toHaveBeenCalledWith( + '/fixed/node-pty/conpty_console_list_agent.js', + ['101'], + { silent: true } + ) + }) + + it.each([ + ['root-only failure fallback', [101]], + ['malformed response', [101, '202']], + ['missing PTY root', [202, 303]] + ])('fails closed for %s', async (_label, processIds) => { + const { forkProcess } = forkWith('message', processIds) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() + }) + + it('handles helper spawn errors without an unhandled child error', async () => { + const { forkProcess } = forkWith('error') + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() + }) + + it('kills a silent helper at the bounded timeout', async () => { + vi.useFakeTimers() + try { + const { child, forkProcess } = forkWith('none') + const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBeNull() + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/providers/windows-conpty-process-membership.ts b/src/main/providers/windows-conpty-process-membership.ts new file mode 100644 index 00000000000..759a234c63f --- /dev/null +++ b/src/main/providers/windows-conpty-process-membership.ts @@ -0,0 +1,74 @@ +import { fork, type ChildProcess } from 'node:child_process' + +const CONPTY_PROCESS_LIST_TIMEOUT_MS = 3_000 + +type ProcessListMessage = { consoleProcessList?: unknown } + +type WindowsConptyMembershipDeps = { + forkProcess?: typeof fork + resolveAgentPath?: () => string + timeoutMs?: number +} + +function resolveNodePtyConsoleListAgent(): string { + return require.resolve('node-pty/lib/conpty_console_list_agent.js') +} + +/** + * Runs node-pty's fixed native console-list helper with bounded error/exit + * handling. A root-only result is its failure fallback, not shell proof. + */ +export function readWindowsConptyProcessIds( + rootPid: number, + deps: WindowsConptyMembershipDeps = {} +): Promise | null> { + if (!Number.isSafeInteger(rootPid) || rootPid <= 0) { + return Promise.resolve(null) + } + let child: ChildProcess + try { + child = (deps.forkProcess ?? fork)( + (deps.resolveAgentPath ?? resolveNodePtyConsoleListAgent)(), + [String(rootPid)], + { silent: true } + ) + } catch { + return Promise.resolve(null) + } + + return new Promise((resolve) => { + let settled = false + const finish = (value: ReadonlySet | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + child.removeListener('message', onMessage) + child.removeListener('error', onFailure) + child.removeListener('exit', onFailure) + resolve(value) + } + const onFailure = (): void => finish(null) + const onMessage = (message: ProcessListMessage): void => { + const value = message?.consoleProcessList + if ( + !Array.isArray(value) || + value.length <= 1 || + !value.includes(rootPid) || + value.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) + ) { + finish(null) + return + } + finish(new Set(value)) + } + const timeout = setTimeout(() => { + child.kill() + finish(null) + }, deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) + child.once('message', onMessage) + child.once('error', onFailure) + child.once('exit', onFailure) + }) +} diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index d26067122a5..6fea54165c8 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -48,14 +48,23 @@ const windowsProcessRowsReader = createProcessTableSnapshotReader { let rows: WindowsProcessRow[] try { - rows = await windowsProcessRowsReader.getSnapshot() + rows = + options.fresh === true + ? await windowsProcessRowsReader.getFreshSnapshot() + : await windowsProcessRowsReader.getSnapshot() } catch { return null } + // Why: a snapshot that omitted the PTY root may be stale or permission- + // filtered; only an observed root can authoritatively have no descendants. + if (!rows.some((row) => row.pid === rootPid)) { + return null + } return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) } @@ -234,7 +243,8 @@ async function queryWindowsProcessesWithWmic(): Promise 0 ? rows : null } catch { // Best-effort: Windows process enumeration may be disabled, so callers // still fall back to node-pty's process name when both probes fail. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ceb0d6a8d54..1001d14f966 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1231,6 +1231,7 @@ export type PreloadApi = { telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ id: string + launchAgent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig snapshot?: string snapshotCols?: number @@ -1280,6 +1281,7 @@ export type PreloadApi = { publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> diff --git a/src/preload/index.ts b/src/preload/index.ts index 4934cd31501..a4d60262a54 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -986,6 +986,8 @@ const api = { /** Return the PTY foreground process basename when available (e.g. "codex"). */ getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }), + confirmForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), /** Resolve the live cwd of a PTY via `/proc` (Linux) or `lsof` (macOS). * Returns `''` when the id is unknown or the platform cannot resolve one. */ diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2231ba66821..8c7f3cd1b03 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1774,6 +1774,7 @@ export default function TerminalPane({ keyboardScopeRef: containerRef, managerRef, paneTransportsRef, + panePtyBindingsRef, paneCwdRef, fallbackCwd: cwd ?? '', expandedPaneIdRef, diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index b5bf46b7d0c..c41a0ea919d 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -2,6 +2,7 @@ * precedence in one ordered handler so shell input, pane commands, search, and * split actions do not race across separate window listeners. */ import { useEffect } from 'react' +import type { IDisposable } from '@xterm/xterm' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { safeFind } from '../terminal-search-safe-find' @@ -28,6 +29,8 @@ import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-in import { useAppStore } from '@/store' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' import { markTerminalFollowOutput, markTerminalPinnedViewport, @@ -148,6 +151,7 @@ type KeyboardHandlersDeps = { keyboardScopeRef: React.RefObject managerRef: React.RefObject paneTransportsRef: React.RefObject> + panePtyBindingsRef: React.RefObject> paneCwdRef: React.RefObject /** Worktree-root cwd used when OSC 7 and pty.getCwd both fail. */ fallbackCwd: string @@ -183,6 +187,7 @@ export function useTerminalKeyboardShortcuts({ keyboardScopeRef, managerRef, paneTransportsRef, + panePtyBindingsRef, paneCwdRef, fallbackCwd, expandedPaneIdRef, @@ -235,6 +240,19 @@ export function useTerminalKeyboardShortcuts({ } } + // Why: this callback is installed once per active tab and invoked only for + // Windows Shift+Enter, keeping store work and allocations off ordinary keys. + const getActivePaneWindowsShiftEnterEncoding = () => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!activePane) { + return 'alt-enter' as const + } + const state = useAppStore.getState() + const paneKey = makePaneKey(tabId, activePane.leafId) + return resolveWindowsShiftEnterEncodingForPane(state, paneKey) + } + const onKeyDown = (e: KeyboardEvent): void => { const manager = managerRef.current if (!manager) { @@ -326,7 +344,8 @@ export function useTerminalKeyboardShortcuts({ keybindings, isLocalWindowsConptyPane, isKittyKeyboardActivePane, - getLayoutBaseCharacterForCode + getLayoutBaseCharacterForCode, + getActivePaneWindowsShiftEnterEncoding ) if (!action) { return @@ -342,6 +361,14 @@ export function useTerminalKeyboardShortcuts({ const sent = paneTransportsRef.current.get(pane.id)?.sendInput(action.data) === true if (sent) { recordTerminalUserInputForLeaf(tabId, pane.leafId) + if (action.data === '\x1b[13;2u') { + // Why: this direct shortcut write does not pass through PTY onData, + // so no-OSC shells need an explicit post-write confirmation ladder. + const binding = panePtyBindingsRef.current.get(pane.id) as + | (IDisposable & { requestDroidReconfirmation?: () => void }) + | undefined + binding?.requestDroidReconfirmation?.() + } } return } diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index ab5aaa03b8c..c5fa06ba8d8 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts @@ -7,21 +7,28 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag const COMMAND_SETTLE_MS = 350 const VISIBLE_PTY_SETTLE_MS = 350 const WRAPPER_RESOLVE_RETRY_MS = 1200 -const SECOND_WRAPPER_RETRY_MS = 3500 +const SECOND_WRAPPER_RETRY_MS = 6000 describe('createPaneForegroundAgentTracker', () => { const readForegroundProcess = vi.fn<(ptyId: string) => Promise>() + const confirmForegroundProcess = vi.fn<(ptyId: string) => Promise>() const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() const onConfirmedShellForeground = vi.fn<() => void>() + const onCommandFinishedUnavailable = vi.fn<() => void>() let ptyId: string | null = 'pty-1' - function makeTracker(): ReturnType { + function makeTracker( + hasKnownAgentIdentity?: () => boolean + ): ReturnType { return createPaneForegroundAgentTracker({ getPtyId: () => ptyId, isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), readForegroundProcess, + confirmForegroundProcess, publish, - onConfirmedShellForeground + hasKnownAgentIdentity, + onConfirmedShellForeground, + onCommandFinishedUnavailable }) } @@ -32,8 +39,11 @@ describe('createPaneForegroundAgentTracker', () => { beforeEach(() => { vi.useFakeTimers() readForegroundProcess.mockReset() + confirmForegroundProcess.mockReset() + confirmForegroundProcess.mockImplementation((id) => readForegroundProcess(id)) publish.mockReset() onConfirmedShellForeground.mockReset() + onCommandFinishedUnavailable.mockReset() ptyId = 'pty-1' }) @@ -56,12 +66,86 @@ describe('createPaneForegroundAgentTracker', () => { expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) }) + it('drops a delayed foreground result after the pane rebinds to another PTY', async () => { + let resolveRead!: (processName: string) => void + readForegroundProcess.mockReturnValue( + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + ptyId = 'pty-2' + resolveRead('droid') + await Promise.resolve() + await Promise.resolve() + + expect(publish).not.toHaveBeenCalledWith({ agent: 'droid', shellForeground: false }) + }) + + it('uses typed-agent text only to await process confirmation', async () => { + readForegroundProcess.mockResolvedValueOnce('powershell.exe').mockResolvedValueOnce('droid') + const tracker = makeTracker() + + tracker.onCommandStarted('droid') + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).toHaveBeenCalledTimes(1) + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + }) + + it('never publishes typed Droid when command-start reads stay unavailable', async () => { + readForegroundProcess.mockResolvedValue(null) + const tracker = makeTracker() + + tracker.onCommandStarted('droid') + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + + await flushSettleRead(SECOND_WRAPPER_RETRY_MS - 1) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + await flushSettleRead(1) + + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + }) + + it('does not replace known foreground identity with typed command inference', () => { + const tracker = makeTracker(() => true) + + tracker.onCommandStarted('droid') + + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + }) + + it('revokes stale routing immediately while a known pane confirms a new command', () => { + const tracker = makeTracker(() => true) + + tracker.onCommandStarted() + + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + it('reads the foreground for a visible PTY so restored running Codex panes regain identity', async () => { readForegroundProcess.mockResolvedValue('codex') const tracker = makeTracker() tracker.onVisiblePtyBound() expect(readForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() await flushSettleRead(VISIBLE_PTY_SETTLE_MS) @@ -194,10 +278,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).not.toHaveBeenCalled() }) - it('confirms a rapid command start->finish instead of trusting the D', async () => { - // Why: a leaked nested-shell 133;C->133;D pair (or a fast real command) - // cancels the command-start read; confirm the foreground so a still-running - // manual agent with no launchAgent/hook keeps its identity. + it('trusts a rapid ordinary command finish without forcing a fresh scan', async () => { readForegroundProcess.mockResolvedValue('claude') const tracker = makeTracker() @@ -205,15 +286,11 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) - expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) - it('confirms duplicate 133;D pairs instead of fast-pathing past the pending confirmation', async () => { - // Why: user shell integrations (iTerm/VS Code) double up Orca's OSC 133, so - // every D arrives twice ~50ms apart. The second D cancels the first D's - // confirming read; it must reschedule the confirmation, not trust the D. + it('keeps duplicate ordinary 133;D pairs on the no-scan shell path', async () => { readForegroundProcess.mockResolvedValue('grok') const tracker = makeTracker() @@ -224,9 +301,8 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'grok', shellForeground: false }) - expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) it('still marks shell without a read for a duplicate D pair on an idle pane', async () => { @@ -252,7 +328,7 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(readForegroundProcess).not.toHaveBeenCalled() expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) @@ -262,7 +338,10 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandStarted() await flushSettleRead(COMMAND_SETTLE_MS) - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + shellForeground: false + }) publish.mockClear() readForegroundProcess.mockClear() @@ -272,12 +351,16 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(COMMAND_SETTLE_MS) expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + routingTrusted: true, + shellForeground: false + }) expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) }) it('marks shell foreground when the confirming read shows the agent is gone', async () => { - readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') + readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValue('zsh') const tracker = makeTracker() tracker.onCommandStarted() @@ -292,11 +375,12 @@ describe('createPaneForegroundAgentTracker', () => { }) it('signals confirmed shell foreground only when the read proves the agent exited', async () => { - // Reads: command-start=codex, first finish=codex (still running), second finish=zsh (exited). + // Reads: command-start=codex, first finish=codex (still running), then a + // bounded zsh→zsh→zsh confirmation for genuine exit. readForegroundProcess .mockResolvedValueOnce('codex') .mockResolvedValueOnce('codex') - .mockResolvedValueOnce('zsh') + .mockResolvedValue('zsh') const tracker = makeTracker() tracker.onCommandStarted() @@ -314,13 +398,15 @@ describe('createPaneForegroundAgentTracker', () => { }) it('returns to the no-RPC finished path after the agent is confirmed gone', async () => { - readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') + readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValue('zsh') const tracker = makeTracker() tracker.onCommandStarted() await flushSettleRead(COMMAND_SETTLE_MS) tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + await flushSettleRead(SECOND_WRAPPER_RETRY_MS) publish.mockClear() readForegroundProcess.mockClear() @@ -336,6 +422,7 @@ describe('createPaneForegroundAgentTracker', () => { getPtyId: () => ptyId, isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), readForegroundProcess, + confirmForegroundProcess, publish, hasKnownAgentIdentity: () => true }) @@ -346,7 +433,120 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(COMMAND_SETTLE_MS) expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + routingTrusted: true, + shellForeground: false + }) + }) + + it('accepts a fresh shell result for a launch-known Droid pane', async () => { + readForegroundProcess.mockResolvedValueOnce('powershell.exe').mockResolvedValueOnce('droid') + const tracker = makeTracker(() => true) + + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + }) + + it('does not let removed launch identity override fresh shell evidence', async () => { + let knownIdentity = true + readForegroundProcess.mockResolvedValueOnce('powershell.exe').mockResolvedValueOnce('droid') + const tracker = makeTracker(() => knownIdentity) + + tracker.onCommandFinished() + // Why: pty-connection clears launch metadata in the same OSC 133;D + // callback, before this tracker's delayed foreground read starts. + knownIdentity = false + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + }) + + it('marks a confirmed shell without repeating a post-boundary scan', async () => { + readForegroundProcess.mockResolvedValue('powershell.exe') + const tracker = makeTracker(() => true) + + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + expect(onConfirmedShellForeground).toHaveBeenCalledTimes(1) + expect(onCommandFinishedUnavailable).not.toHaveBeenCalled() + }) + + it('retries a null foreground result once for a known Droid pane', async () => { + readForegroundProcess.mockResolvedValueOnce(null).mockResolvedValueOnce('droid') + const tracker = makeTracker(() => true) + + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledExactlyOnceWith({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + }) + + it('retries a rejected foreground read once for a known Droid pane', async () => { + readForegroundProcess.mockRejectedValueOnce(new Error('inspection unavailable')) + readForegroundProcess.mockResolvedValueOnce('droid') + const tracker = makeTracker(() => true) + + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledExactlyOnceWith({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + }) + + it('retires known identity when the bounded command-finish ladder remains unavailable', async () => { + readForegroundProcess.mockResolvedValue(null) + const tracker = makeTracker(() => true) + + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + await flushSettleRead(SECOND_WRAPPER_RETRY_MS) + + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(onConfirmedShellForeground).not.toHaveBeenCalled() + expect(onCommandFinishedUnavailable).toHaveBeenCalledTimes(1) + }) + + it('rebuilds visible launch identity through provisional daemon shell results', async () => { + readForegroundProcess + .mockResolvedValueOnce('powershell.exe') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('droid') + const tracker = makeTracker(() => false) + + tracker.onVisiblePtyBound(true) + await flushSettleRead(VISIBLE_PTY_SETTLE_MS) + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(SECOND_WRAPPER_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).toHaveBeenCalledExactlyOnceWith({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) }) it('never reads or publishes for remote or ssh panes', async () => { diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index d3e613f6845..189b91573b6 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -2,15 +2,15 @@ import { isAgentForegroundWrapperProcess, recognizeAgentProcess } from '../../../../shared/agent-process-recognition' +import { isShellProcess } from '../../../../shared/shell-process-detection' +import type { TuiAgent } from '../../../../shared/types' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' -// Why: the read must land after the shell has exec'd the command; and when it -// still sees a node/python wrapper, the daemon resolves that ancestry -// asynchronously — observed to take >1.5s for real node-wrapped CLIs — so -// give its cache two bounded re-reads, not an open-ended retry loop. +// Why: settle after exec, then place the final generic retry beyond sequential +// 3s PowerShell and WMIC enrichment scans. const COMMAND_SETTLE_MS = 350 const VISIBLE_PTY_SETTLE_MS = 350 -const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const +const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 6000] as const type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' type PaneForegroundAgentTrackerDeps = { @@ -19,6 +19,8 @@ type PaneForegroundAgentTrackerDeps = { * their replayed OSC streams must not produce process evidence. */ isTrackablePtyId: (ptyId: string) => boolean readForegroundProcess: (ptyId: string) => Promise + /** Fresh, provider-owned evidence used only when input routing may change. */ + confirmForegroundProcess?: (ptyId: string) => Promise publish: (entry: PaneForegroundAgentEntry) => void /** True when the pane is otherwise known to run an agent (launchAgent, live * hook status). Lets a restored agent pane confirm — rather than trust — a @@ -27,7 +29,9 @@ type PaneForegroundAgentTrackerDeps = { /** Fired when a confirming read proves the foreground genuinely returned to a * shell (agent exited). Lets callers clear a stale agent-named tab title that * the shell never repaints. */ - onConfirmedShellForeground?: () => void + onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished') => void + onCommandFinishedUnavailable?: () => void + onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void } /** @@ -39,9 +43,10 @@ type PaneForegroundAgentTrackerDeps = { * leak their own 133;D onto the main PTY. */ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): { - onVisiblePtyBound: () => void - onCommandStarted: () => void - onCommandFinished: () => void + onVisiblePtyBound: (expectsAgent?: boolean) => boolean + onCommandStarted: (expectedAgent?: TuiAgent | null) => void + /** True when pane identity must remain visible until an async shell confirmation. */ + onCommandFinished: () => boolean dispose: () => void } { let disposed = false @@ -53,6 +58,10 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // OSC 133;D leaks onto the main PTY. For a pane an agent has owned, that D is // not proof the prompt returned, so confirm the foreground before clearing. let hasForegroundAgentEvidence = false + // Why: latch launch/hook evidence until confirmation finishes so cleanup + // cannot remove the identity that authorizes the bounded retry ladder. + let hasKnownAgentEvidence = false + let hasAgentExpectation = false const trackablePtyId = (): string | null => { const ptyId = deps.getPtyId() @@ -98,18 +107,35 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } let processName: string | null = null + const requiresRoutingConfirmation = + reason === 'command-finished' || + hasForegroundAgentEvidence || + hasKnownAgentEvidence || + hasAgentExpectation try { - processName = await deps.readForegroundProcess(ptyId) + processName = await (requiresRoutingConfirmation + ? (deps.confirmForegroundProcess ?? deps.readForegroundProcess)(ptyId) + : deps.readForegroundProcess(ptyId)) } catch { processName = null } - if (disposed || generation !== readGeneration) { + // Why: a pane key can be rebound while process inspection is pending; the + // old PTY's identity must never publish into its replacement session. + if (disposed || generation !== readGeneration || trackablePtyId() !== ptyId) { return } const recognized = recognizeAgentProcess(processName) if (recognized) { hasForegroundAgentEvidence = true - deps.publish({ agent: recognized.agent, shellForeground: false }) + hasAgentExpectation = false + deps.publish({ + agent: recognized.agent, + shellForeground: false, + ...(requiresRoutingConfirmation ? { routingTrusted: true } : {}) + }) + if (reason === 'visible-pty') { + deps.onVisibleForegroundSettled?.('agent') + } return } // Why: a shell seen here is NOT prompt proof — 133;D cancels pending reads, @@ -117,32 +143,72 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // a nested one (sh/bash without integration); marking shell-foreground // would suppress live title identity. Only 133;D proves the prompt. const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] + const hasConfirmationExpectation = + hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation + const shouldRetryExpectedIdentity = + hasConfirmationExpectation && (reason !== 'command-finished' || processName === null) const shouldRetry = retryDelay !== undefined && - processName && - (reason === 'command' || isAgentForegroundWrapperProcess(processName)) + (shouldRetryExpectedIdentity || + (processName !== null && + (reason === 'command' || isAgentForegroundWrapperProcess(processName)))) if (shouldRetry) { + // Why: provisional PowerShell may hide a live agent; the bounded ladder + // spans PowerShell-to-WMIC enrichment without becoming a polling loop. scheduleRead(retryDelay, retryIndex + 1, reason) return } if (reason === 'command') { + hasAgentExpectation = false deps.publish({ agent: null, shellForeground: false }) return } + if (reason === 'visible-pty') { + if ( + (hasForegroundAgentEvidence || hasKnownAgentEvidence) && + processName !== null && + isShellProcess(processName) + ) { + hasForegroundAgentEvidence = false + hasKnownAgentEvidence = false + hasAgentExpectation = false + deps.publish({ agent: null, shellForeground: true }) + deps.onConfirmedShellForeground?.(reason) + deps.onVisibleForegroundSettled?.('shell') + } else { + deps.onVisibleForegroundSettled?.('inconclusive') + } + return + } if (reason === 'command-finished') { + if (processName === null) { + // Why: unavailable inspection is not confirmed shell evidence; retire + // stale routing after the bounded D ladder without asserting shell truth. + hasForegroundAgentEvidence = false + hasKnownAgentEvidence = false + hasAgentExpectation = false + deps.publish({ agent: null, shellForeground: false }) + deps.onCommandFinishedUnavailable?.() + return + } + if ((hasForegroundAgentEvidence || hasKnownAgentEvidence) && !isShellProcess(processName)) { + return + } // Why: the 133;D fired AND the foreground shows no agent — together that is // real prompt proof, so the agent truly exited. Reset the evidence so the // pane's ordinary shell commands go back to the no-RPC finished path. hasForegroundAgentEvidence = false + hasKnownAgentEvidence = false + hasAgentExpectation = false deps.publish({ agent: null, shellForeground: true }) // Why: confirmed exit — let callers clear a stale agent title the shell // won't repaint (a plain `codex`/`grok` leaves its OSC title behind). - deps.onConfirmedShellForeground?.() + deps.onConfirmedShellForeground?.(reason) } } return { - onVisiblePtyBound() { + onVisiblePtyBound(expectsAgent = false) { // Why: command-start and command-finished reads own the exit decision; // visibility recovery is lower-authority and must never cancel them. if ( @@ -151,27 +217,39 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke scheduledReadReason === 'command-finished' || activeReadReason === 'command-finished' ) { - return + return false } cancelPendingRead() if (!trackablePtyId()) { - return + return false + } + if (expectsAgent || deps.hasKnownAgentIdentity?.() === true) { + hasKnownAgentEvidence = true } // Why: restored/manual agent panes can become visible while Codex is // already foreground, so no OSC 133 command-start event will seed the tab icon. scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') + return true }, - onCommandStarted() { + onCommandStarted(expectedAgent = null) { cancelPendingRead() if (!trackablePtyId()) { return } - // Why: the foreground left the prompt the moment C fired — stale - // shell-foreground evidence must not clear the command that just started. + const alreadyHasKnownIdentity = deps.hasKnownAgentIdentity?.() === true + hasAgentExpectation = expectedAgent !== null + if (alreadyHasKnownIdentity) { + hasKnownAgentEvidence = true + } + // Why: every new command invalidates the previous byte-routing authority. + // Launch/hook identity remains only an expectation until fresh evidence. deps.publish({ agent: null, shellForeground: false }) scheduleRead(COMMAND_SETTLE_MS, 0, 'command') }, onCommandFinished() { + if (deps.hasKnownAgentIdentity?.() === true) { + hasKnownAgentEvidence = true + } // Why: a rapid 133;C→133;D pair cancels the command-start read before it // can identify the foreground — that pair is exactly a leaked nested-shell // command under a full-screen agent (or a fast real shell command), so on a @@ -182,25 +260,21 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // a D that cancels one must re-confirm — never fast-path to shell, which the // sampleVisiblePaneForegroundAgent gate would then latch, permanently hiding // an idle reattached agent's icon (the "codex reattached at rest" bug). - const identityReadWasPending = scheduledReadReason !== null || activeReadReason !== null cancelPendingRead() if (!trackablePtyId()) { - return + return false } // Why: trust the 133;D and mark shell without an RPC only when nothing hints // at an agent — no prior agent evidence, no launch/hook identity, and no // identity read racing this finish. - if ( - !hasForegroundAgentEvidence && - deps.hasKnownAgentIdentity?.() !== true && - !identityReadWasPending - ) { + if (!hasForegroundAgentEvidence && !hasKnownAgentEvidence && !hasAgentExpectation) { deps.publish({ agent: null, shellForeground: true }) - return + return false } // Why: confirm the foreground before clearing — if the agent still owns it, // the read republishes its identity; only a genuine shell result clears it. scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished') + return true }, dispose() { disposed = true diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index fe1b2e51c30..895fe1eb503 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -13,10 +13,11 @@ import { } from './layout-serialization' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' +import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' import type * as UseNotificationDispatchModule from './use-notification-dispatch' import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' import { @@ -110,7 +111,13 @@ type StoreState = { activeWorktreeId: string | null tabsByWorktree: Record< string, - { id: string; ptyId: string | null; title?: string; launchAgent?: string }[] + { + id: string + ptyId: string | null + title?: string + launchAgent?: string + shellOverride?: string + }[] > ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record @@ -177,7 +184,10 @@ type StoreState = { paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record suppressedPtyExitIds: Record - agentLaunchConfigByPaneKey: Record + agentLaunchConfigByPaneKey: Record< + string, + { launchConfig: unknown; identity?: { agentType?: string } } + > getAgentLaunchConfigForStatusEntry: ReturnType getAgentLaunchConfigForStatusMetadata: ReturnType clearSleepingAgentSession: ReturnType @@ -196,6 +206,22 @@ type StoreState = { markAgentCompletionPaneUnread: ReturnType } +type WindowsShiftEnterPaneState = Parameters[0] + +function resolveMockPaneWindowsShiftEnterEncoding( + state: StoreState, + paneKey: string +): ReturnType { + return resolveWindowsShiftEnterEncodingForPane( + { + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey, + agentLaunchConfigByPaneKey: + state.agentLaunchConfigByPaneKey as WindowsShiftEnterPaneState['agentLaunchConfigByPaneKey'] + }, + paneKey + ) +} + type ConnectCallbacks = { onData?: ( data: string, @@ -823,6 +849,7 @@ describe('connectPanePty', () => { reportGeometry: vi.fn(), getMainBufferSnapshot: vi.fn().mockResolvedValue(null), getForegroundProcess: vi.fn().mockResolvedValue(null), + confirmForegroundProcess: vi.fn().mockResolvedValue(null), hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), @@ -853,6 +880,9 @@ describe('connectPanePty', () => { addEventListener: vi.fn(), removeEventListener: vi.fn() } + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation((id) => + window.api.pty.getForegroundProcess(id) + ) globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) return 1 @@ -4936,6 +4966,416 @@ describe('connectPanePty', () => { expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() }) + it('routes a manually typed Droid only after foreground enrichment confirms it', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = 'pty-manually-typed-droid' + const tabId = 'tab-manually-typed-droid' + const foregroundResults = ['powershell.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async (id: string) => + id === ptyId ? (foregroundResults.shift() ?? 'droid') : null + ) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey(tabId, LEAF_1) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + dataCallbackRef.current?.('\x1b]133;C\x07') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(350) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(1200) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + }) + + it('confirms a manually typed Droid without OSC command boundaries', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-manual-droid-no-osc' + const tabId = 'tab-manual-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(350) + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('cmd.exe') + const pane = createPane(1) + const ptyId = 'pty-droid-exit-no-osc' + const tabId = 'tab-droid-exit-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty(pane as never, createManager(1) as never, createDeps({ tabId }) as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it('never promotes typed Droid text when foreground enrichment is unavailable', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('inspection unavailable')) + .mockResolvedValueOnce(null) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = 'pty-typed-droid-unavailable-start' + const tabId = 'tab-typed-droid-unavailable-start' + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07') + await vi.advanceTimersByTimeAsync(350) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(1200) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(5999) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + await vi.advanceTimersByTimeAsync(1) + + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it.each(['process', 'launch'] as const)( + 'does not let typed Droid input replace another live TUI %s identity', + async (identitySource) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const ptyId = `pty-antigravity-${identitySource}-typed-droid` + const tabId = `tab-antigravity-${identitySource}-typed-droid` + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + if (identitySource === 'process') { + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'antigravity', + shellForeground: false + } + } else { + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'antigravity' } + } + } + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07') + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + } + ) + + it.each([ + ['SSH', 'ssh:conn@@pty-typed-droid', 'tab-ssh-typed-droid'], + ['remote runtime', 'remote:web-env-1@@pty-typed-droid', 'tab-remote-typed-droid'] + ])( + 'does not persist typed command process evidence for %s panes', + async (_label, ptyId, tabId) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + sendTerminalInputThroughPane(pane, 'droid\r') + dataCallbackRef.current?.('\x1b]133;C\x07\x1b]133;D;0\x07') + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( + paneKey, + expect.anything() + ) + } + ) + + it('keeps Droid routing visible through command-finished foreground confirmation', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.confirmForegroundProcess) + getForegroundProcess.mockResolvedValue('droid') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-confirmation-window' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + const readsBeforeFinish = getForegroundProcess.mock.calls.length + dataCallbackRef.current?.('\x1b]133;D;0\x07') + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + + expect(getForegroundProcess).toHaveBeenCalledTimes(readsBeforeFinish + 1) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('retires pane launch routing after one fresh scan confirms shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-confirmed-shell' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-unavailable-finish' + const tabId = 'tab-droid-unavailable-finish' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey(tabId, LEAF_1) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + + it('cancels deferred cleanup on a new command and retires identity at its later shell', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-droid-superseded-confirmation' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + dataCallbackRef.current?.('\x1b]133;C\x07') + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + }) + it('flushes pending interrupt inference before dropping an exited foreground agent command', async () => { const { connectPanePty } = await import('./pty-connection') @@ -16966,6 +17406,8 @@ describe('connectPanePty', () => { describe('visible foreground agent sampling (perf)', () => { const VISIBLE_PTY_SETTLE_MS = 350 + const WRAPPER_RESOLVE_RETRY_MS = 1200 + const SECOND_WRAPPER_RETRY_MS = 6000 // Why: every connectPanePty binding in this file shares the tab-1/LEAF_1 pane // key, and an undisposed reattach binding elsewhere can resolve a foreground @@ -16976,9 +17418,19 @@ describe('connectPanePty', () => { ptyId?: string tabId?: string isVisibleRef?: { current: boolean } + launchConfig?: { + agentCommand?: string + agentArgs: string + agentEnv: Record + } + launchAgent?: TuiAgent } = {} ): Promise<{ - binding: { noteVisibilityResume: () => void } + binding: { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestDroidReconfirmation: () => void + } deps: ReturnType transport: MockTransport cacheKey: string @@ -16986,9 +17438,19 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const ptyId = args.ptyId ?? 'tab-pty' const tabId = args.tabId ?? `tab-${ptyId}` - const transport = createMockTransport(ptyId) + const hasReattachMetadata = args.launchConfig !== undefined || args.launchAgent !== undefined + const transport = createMockTransport(hasReattachMetadata ? null : ptyId) + let connectedPtyId: string | null = hasReattachMetadata ? null : ptyId + transport.getPtyId.mockImplementation(() => connectedPtyId) transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - return sessionId ? { id: sessionId } : null + connectedPtyId = sessionId ?? null + return sessionId + ? { + id: sessionId, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) + } + : null }) transportFactoryQueue.push(transport) const deps = createDeps({ @@ -17001,7 +17463,11 @@ describe('connectPanePty', () => { createPane(1) as never, createManager(1) as never, deps as never - ) as unknown as { noteVisibilityResume: () => void } + ) as unknown as { + noteVisibilityResume: () => void + sampleForegroundAgentOnFocus: () => void + requestDroidReconfirmation: () => void + } await vi.advanceTimersByTimeAsync(20) await flushAsyncTicks(20) return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } @@ -17084,6 +17550,92 @@ describe('connectPanePty', () => { expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) }) + it('does not confirm foreground routing for a Windows WSL pane', async () => { + vi.useFakeTimers() + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const ptyId = 'pty-wsl-no-confirm' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, shellOverride: 'wsl.exe' }] + } + + try { + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(10_000) + + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + } finally { + restoreUserAgent() + } + }) + + it('keeps trusted Droid routing through a rapid Shift+Enter burst', async () => { + vi.useFakeTimers() + const ptyId = 'pty-droid-shift-enter-burst' + const tabId = `tab-${ptyId}` + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId + }) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + mockStoreState.agentStatusByPaneKey[cacheKey] = { + state: 'working', + agentType: 'droid' + } + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + + binding.requestDroidReconfirmation() + await vi.advanceTimersByTimeAsync(200) + binding.requestDroidReconfirmation() + await vi.advanceTimersByTimeAsync(349) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + + await vi.advanceTimersByTimeAsync(1) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + shellForeground: false + }) + + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + + binding.requestDroidReconfirmation() + await vi.advanceTimersByTimeAsync(700) + await flushAsyncTicks() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + }) + it('samples once when an identityless hidden pane resumes visible', async () => { vi.useFakeTimers() const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) @@ -17101,20 +17653,162 @@ describe('connectPanePty', () => { expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) }) - it('does not sample when launch metadata already supplies tab identity', async () => { + it('confirms daemon launch identity before restoring warm-reattach routing', async () => { vi.useFakeTimers() - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') const ptyId = 'pty-launch-identity-no-sample' const tabId = `tab-${ptyId}` - mockStoreState.tabsByWorktree = { - 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] - } + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'droid' + }) + expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') - await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) await advanceVisibleForegroundRead() - expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') + }) + + it('retires stale daemon launch identity when warm reattach finds the shell', async () => { + vi.useFakeTimers() + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') + const ptyId = 'pty-stale-daemon-launch-identity' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'droid' + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('fails closed when a warm reattach has no persisted launch identity', async () => { + vi.useFakeTimers() + const ptyId = 'pty-reattach-missing-launch-identity' + const tabId = `tab-${ptyId}` + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false } + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toBeUndefined() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('does not let persisted args spoof pane identity without an allowlisted command', async () => { + vi.useFakeTimers() + const ptyId = 'pty-unknown-persisted-launch' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.registerAgentLaunchConfig.mockImplementation( + (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } + } + ) + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchConfig: { + agentCommand: 'custom-wrapper --agent droid', + agentArgs: 'droid', + agentEnv: {} + } + }) + await vi.advanceTimersByTimeAsync(300) + await flushAsyncTicks(20) + + expect( + mockStoreState.agentLaunchConfigByPaneKey[cacheKey]?.identity?.agentType + ).toBeUndefined() + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + }) + + it('does not re-scan stale launch metadata after the local ladder confirms shell', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('powershell.exe') + const isVisibleRef = { current: false } + const ptyId = 'pty-stale-launch-shell-settled' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, launchAgent: 'droid' }] + } + mockStoreState.registerAgentLaunchConfig.mockImplementation( + (key: string, launchConfig: unknown, identity: { agentType?: string }): void => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { launchConfig, identity } + } + ) + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) + + const { binding, cacheKey } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef, + launchConfig: { agentCommand: 'droid', agentArgs: '', agentEnv: {} } + }) + await vi.advanceTimersByTimeAsync(300) + await flushAsyncTicks(20) + // Launch metadata starts confirmation but is never byte-routing authority. + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + isVisibleRef.current = true + binding.noteVisibilityResume() + await advanceVisibleForegroundRead() + await vi.advanceTimersByTimeAsync(WRAPPER_RESOLVE_RETRY_MS) + await vi.advanceTimersByTimeAsync(SECOND_WRAPPER_RETRY_MS) + + expect(foregroundReadCallsFor(ptyId).length).toBeGreaterThanOrEqual(3) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) + + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: null, + shellForeground: false + } + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') + + vi.clearAllTimers() + const settledReadCount = foregroundReadCallsFor(ptyId).length + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(settledReadCount) }) it('does not sample when a live hook row already supplies pane identity', async () => { @@ -17142,6 +17836,7 @@ describe('connectPanePty', () => { const tabId = `tab-${ptyId}` mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { agent: 'codex', + routingTrusted: true, shellForeground: false } @@ -17168,10 +17863,7 @@ describe('connectPanePty', () => { expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) }) - it('re-samples a shell-marked pane a launch agent still owns', async () => { - // Why: a reattach or a full-screen agent's leaked nested-shell 133;D leaves - // shellForeground on a launchAgent pane, suppressing its icon. The launch - // metadata means an agent is expected, so re-read to recover its identity. + it('does not re-sample a shell-confirmed pane from stale launch metadata', async () => { vi.useFakeTimers() const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) getForegroundProcess.mockResolvedValue('codex') @@ -17188,19 +17880,17 @@ describe('connectPanePty', () => { const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) await advanceVisibleForegroundRead() - expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'codex', - shellForeground: false - }) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith( + cacheKey, + expect.anything() + ) }) - it('re-confirms rather than trusting a leaked 133;D that races the recovery read', async () => { - // Why: an idle reattached agent (no launchAgent/hook/title) schedules the - // visible-pty recovery read. A full-screen agent's nested shell then leaks a - // 133;D onto the main PTY. Trusting that D would mark the pane shell- - // foreground and the sample gate would latch it, hiding the agent icon - // forever — instead the leak must re-confirm the foreground. + it('fails closed when a leaked 133;D cancels identityless recovery', async () => { + // Why: a cached visible read has no routing authority. If a command + // boundary races it without an agent hint, cancel the sample and trust + // the shell marker instead of promoting stale process identity. vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) @@ -17230,12 +17920,58 @@ describe('connectPanePty', () => { await advanceVisibleForegroundRead() + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: null, + shellForeground: true + }) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('re-confirms leaked 133;D after detach moved pane-scoped Droid identity', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('droid') + const ptyId = 'pty-detached-droid-leaked-d' + const tabId = 'tab-detached-droid' + const cacheKey = makePaneKey(tabId, LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return sessionId ? { id: sessionId } : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'droid', + shellForeground: false + } + mockStoreState.agentLaunchConfigByPaneKey[cacheKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'droid' } + } + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalledWith(cacheKey, { agent: null, shellForeground: true }) expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'codex', + agent: 'droid', + routingTrusted: true, shellForeground: false }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f93bba90068..d1345865cf1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -215,7 +215,7 @@ import { } from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' import { @@ -273,6 +273,7 @@ const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 +const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 @@ -598,6 +599,8 @@ type PanePtyBinding = IDisposable & { * icon follows the active leaf, and a shell-marked entry on a still-running * agent pane has no OSC boundary left to correct it. */ sampleForegroundAgentOnFocus: () => void + /** Reconfirm after direct shortcut input, which bypasses PTY onData. */ + requestDroidReconfirmation: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void /** True when the hidden-delivery gate structurally manages the pane's @@ -966,6 +969,7 @@ export function connectPanePty( let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null let alternateScreenBackgroundRepaintTimer: ReturnType | null = null + let shiftEnterReconfirmTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false // Why: tracks the keystroke proximity captured when the current synchronized // foreground frame opened, so a split end marker that lands after the redraw @@ -1146,11 +1150,27 @@ export function connectPanePty( metadata?: { launchToken?: string; launchAgent?: TuiAgent } ): void => { if (!effectiveLaunchConfig) { + if (metadata?.launchAgent) { + // Why: daemon launch identity can outlive the process while Orca is + // closed. Use it to request confirmation, never as current byte authority. + useAppStore.getState().setPaneForegroundAgent(cacheKey, { + agent: metadata.launchAgent, + shellForeground: false + }) + } return } + // Why: daemon reattach preserves the pane's exact launch command but not + // renderer metadata; recover only allowlisted command identity from it. + const persistedLaunchAgent = recognizeAgentProcessFromCommandLine( + effectiveLaunchConfig.agentCommand + )?.agent useAppStore.getState().registerAgentLaunchConfig(cacheKey, effectiveLaunchConfig, { agentType: - metadata?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent, + metadata?.launchAgent ?? + paneStartup?.launchAgent ?? + paneStartup?.initialAgentStatus?.agent ?? + persistedLaunchAgent, ...((metadata?.launchToken ?? launchToken) ? { launchToken: metadata?.launchToken ?? launchToken } : {}), @@ -1177,6 +1197,8 @@ export function connectPanePty( let pendingShellCommandCursor = 0 let commandInferredPaneAgentGeneration = 0 let shellCommandInferenceSuspendedUntilCommandEnd = false + let startAcceptedInferredCommand = (_agent: TuiAgent): void => {} + let requestKnownDroidReconfirmation = (): void => {} const resetPendingShellCommandLine = (): void => { pendingShellCommandLine = '' pendingShellCommandCursor = 0 @@ -1184,11 +1206,22 @@ export function connectPanePty( const rememberCommandInferredPaneAgent = (): void => { const commandLine = pendingShellCommandLine.trim() resetPendingShellCommandLine() - const nextAgent = commandLine + const candidateAgent = commandLine ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) : null + const state = useAppStore.getState() + const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType + // Why: input inside a live TUI can spell another agent command; process or + // pane-scoped launch ownership is stronger than typed shell inference. + const nextAgent = + state.paneForegroundAgentByPaneKey[cacheKey]?.agent || isTuiAgent(registeredLaunchAgent) + ? null + : candidateAgent commandInferredPaneAgent = nextAgent commandInferredPaneAgentGeneration += 1 + if (nextAgent) { + startAcceptedInferredCommand(nextAgent) + } } const clearCommandInferredPaneAgent = (): void => { commandInferredPaneAgent = null @@ -1404,6 +1437,16 @@ export function connectPanePty( return detectAgentStatusFromTitle(liveTitle ?? '') !== null } const observeAcceptedShellCommandInput = (data: string): void => { + if ( + data.includes('\r') || + data.includes('\n') || + data.includes('\x03') || + data.includes('\x04') + ) { + // Why: shells without OSC 133 give no command/exit boundary. An accepted + // submit or interrupt revokes only stale Droid routing and confirms once. + requestKnownDroidReconfirmation() + } if (commandInferredPaneAgent) { return } @@ -1786,8 +1829,12 @@ export function connectPanePty( const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( (candidate) => candidate.id === deps.tabId ) + const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType return Boolean( - tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent + tab?.launchAgent ?? + paneStartup?.launchAgent ?? + paneStartup?.initialAgentStatus?.agent ?? + (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) ) } // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a @@ -1795,7 +1842,12 @@ export function connectPanePty( // even on a restore where no command-start read has recorded evidence yet. const paneHasKnownAgentIdentity = (): boolean => { const state = useAppStore.getState() - return paneHasLiveHookAgentIcon(state) || paneExpectsLaunchAgent(state) + const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType + return ( + Boolean(state.paneForegroundAgentByPaneKey[cacheKey]?.agent) || + paneHasLiveHookAgentIcon(state) || + isTuiAgent(registeredLaunchAgent) + ) } // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints // it on exit, so a confirmed return-to-shell must clear a title that still @@ -1817,13 +1869,55 @@ export function connectPanePty( deps.updateTabTitle(deps.tabId, neutralTitle) } } + let deferredCommandFinishedStatusDrop: (() => void) | null = null + let visibleForegroundSamplePending = false + let visibleForegroundSampleSettled = false + const settleDeferredCommandFinishedStatusDrop = (): void => { + const dropStatus = deferredCommandFinishedStatusDrop + deferredCommandFinishedStatusDrop = null + dropStatus?.() + } + const isForegroundTrackingAllowed = (id: string): boolean => { + if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) { + return false + } + if (!navigator.userAgent.includes('Windows')) { + return true + } + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + // Why: WSL and remote-runtime panes can never authorize native ConPTY + // bytes, so do not pay for Windows process scans that cannot affect routing. + return isLocalNativeWindowsConpty({ + userAgent: navigator.userAgent, + connectionId: getConnectionId(deps.worktreeId) ?? null, + cwd: deps.cwd, + shellOverride: tab?.shellOverride, + executionHostId: getExecutionHostIdForWorktree(state, deps.worktreeId) + }) + } const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ getPtyId: () => transport.getPtyId(), - isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null, + isTrackablePtyId: isForegroundTrackingAllowed, readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), + confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id), publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry), hasKnownAgentIdentity: paneHasKnownAgentIdentity, - onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell + onConfirmedShellForeground: (reason) => { + clearStaleAgentTabTitleOnConfirmedShell() + if (reason === 'visible-pty') { + useAppStore.getState().clearAgentLaunchConfig(cacheKey) + return + } + settleDeferredCommandFinishedStatusDrop() + }, + onCommandFinishedUnavailable: settleDeferredCommandFinishedStatusDrop, + onVisibleForegroundSettled: (outcome) => { + visibleForegroundSamplePending = false + visibleForegroundSampleSettled = outcome !== 'inconclusive' + } }) // Why: one command-finished policy whether the signal arrives as bytes // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — @@ -1831,66 +1925,101 @@ export function connectPanePty( // identical across authority modes. const handleCommandFinished = (_bestEffortExitCode: number | null): void => { clearCommandInferredPaneAgentAfterPtySideEffects() - paneForegroundAgentTracker.onCommandFinished() + visibleForegroundSamplePending = false + const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(deps.worktreeId) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() - if (inferenceResult === true) { - // Why: OSC 133 D means the foreground shell command exited. If an - // interrupt was inferred first, drop only when the current interrupted - // row is still the same turn; otherwise a killed OpenCode CLI leaves a - // stale "interrupted" row even though the process is gone. - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true + const dropStatus = (): void => { + if (inferenceResult === true) { + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) }) - }) + return + } + dropCommandFinishedStatusIfSameTurn(entry) + } + if (shouldDeferStatusDrop) { + // Why: keep the concrete pane identity routable while the local process + // check distinguishes a leaked nested-shell D from a genuine agent exit. + deferredCommandFinishedStatusDrop = dropStatus return } - // Why: OSC 133 D marks the foreground shell command exiting. Remove the - // row without retaining a done snapshot; this section represents a live - // agent process, and the shell prompt means that process is gone. - dropCommandFinishedStatusIfSameTurn(entry) + deferredCommandFinishedStatusDrop = null + dropStatus() } - const sampleVisiblePaneForegroundAgent = (): void => { - if (!deps.isVisibleRef.current) { + const sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { + if ( + !deps.isVisibleRef.current || + visibleForegroundSamplePending || + visibleForegroundSampleSettled + ) { return } const state = useAppStore.getState() const foreground = state.paneForegroundAgentByPaneKey[cacheKey] - // Why: live process identity already paints the icon — nothing to read. - if (foreground?.agent) { + // Why: a daemon reattach may restore display identity without current + // routing authority. Only fresh evidence can suppress its confirmation. + if (foreground?.agent && foreground.routingTrusted === true) { return } - if (paneHasLiveHookAgentIcon(state)) { + if (!forceRoutingConfirmation && paneHasLiveHookAgentIcon(state)) { return } const expectsAgent = paneExpectsLaunchAgent(state) - // Why: with no shell mark yet, launchAgent bootstrap already paints the icon, - // so a read is pointless. Otherwise probe the foreground: a shell mark (from a - // reattach or a full-screen agent's leaked nested-shell 133;D) can hide a - // still-running agent the launch/hook identity says is expected, which no - // later 133;C will reseed. A genuinely idle shell just returns a shell name - // and publishes nothing, so the probe is self-limiting. - if (!foreground?.shellForeground && expectsAgent) { + // Why: a completed local process ladder is stronger than stale tab/startup + // launch metadata. Command-start clears this mark if the pane becomes busy. + if (foreground?.shellForeground) { return } - // Why: a 133;D already proved this pane is back at a shell prompt; with no - // agent expectation there is nothing to recover, so trust it and don't re-read. - if (foreground?.shellForeground && !expectsAgent) { + // Why: tab launch metadata can leak across split panes; rebuild pane-scoped + // identity from local process state, with remote/SSH excluded by the tracker. + visibleForegroundSamplePending = paneForegroundAgentTracker.onVisiblePtyBound(expectsAgent) + } + startAcceptedInferredCommand = (agent) => { + paneForegroundAgentTracker.onCommandStarted(agent) + } + requestKnownDroidReconfirmation = () => { + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] + // Why: daemon reattach/launch metadata is display-only until a live + // provider read confirms it. Submit/interrupt/title-exit evidence must + // revoke that launch-only hint too, otherwise Shift+Enter can route bytes + // to a Droid that already exited before confirmation ever ran. + if (foreground?.agent !== 'droid') { return } - paneForegroundAgentTracker.onVisiblePtyBound() + // Why: cmd.exe and Git Bash have no OSC command boundaries. Keep the icon + // as a hint, but revoke bytes until one current provider confirmation lands. + useAppStore.getState().setPaneForegroundAgent(cacheKey, { + agent: 'droid', + shellForeground: false + }) + visibleForegroundSamplePending = false + visibleForegroundSampleSettled = false + // Why: hook rows can suppress display-only sampling, but cannot restore + // byte authority after this function explicitly revoked routing trust. + sampleVisiblePaneForegroundAgent(true) } const commandLifecycle = createTerminalCommandLifecycle({ - onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), + onCommandStarted: () => { + // Why: a new command invalidates cleanup waiting on the previous D; only + // a later confirmed shell boundary may retire this pane's live identity. + deferredCommandFinishedStatusDrop = null + visibleForegroundSamplePending = false + visibleForegroundSampleSettled = false + // Why: typed commands can be aliases, so they only widen the bounded + // process-confirmation window; they never become routing evidence. + paneForegroundAgentTracker.onCommandStarted(commandInferredPaneAgent) + }, onCommandFinished: handleCommandFinished }) // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — @@ -2849,6 +2978,7 @@ export function connectPanePty( const onAgentExited = (): void => { clearSuppressedTitleSideEffects() clearCommandInferredPaneAgent() + requestKnownDroidReconfirmation() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -6259,7 +6389,11 @@ export function connectPanePty( } registerEffectiveLaunchConfig(connectResult?.launchConfig, { ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(coldRestoreStartup ? { launchAgent: coldRestoreStartup.agent } : {}) + ...(connectResult?.launchAgent + ? { launchAgent: connectResult.launchAgent } + : coldRestoreStartup + ? { launchAgent: coldRestoreStartup.agent } + : {}) }) if (connectResult?.sessionExpired) { if (staleSessionId) { @@ -7094,6 +7228,7 @@ export function connectPanePty( noteVisibilityResume() { ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() + requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, // Why: mobile wake reaches this pane while it stays hidden on the desktop, so @@ -7138,8 +7273,21 @@ export function connectPanePty( return null }, sampleForegroundAgentOnFocus() { + requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, + requestDroidReconfirmation() { + if (shiftEnterReconfirmTimer !== null) { + clearTimeout(shiftEnterReconfirmTimer) + } + // Why: preserve rapid multiline input inside a confirmed Droid composer; + // confirm only after the Shift+Enter burst goes idle. + shiftEnterReconfirmTimer = setTimeout(() => { + shiftEnterReconfirmTimer = null + requestKnownDroidReconfirmation() + sampleVisiblePaneForegroundAgent() + }, SHIFT_ENTER_RECONFIRM_IDLE_MS) + }, reconcileIfSessionDead, reconcileIfSessionMissing, dispose() { @@ -7166,6 +7314,10 @@ export function connectPanePty( interruptInference.dispose() clearTitleOnlyInterruptTimer() clearCommandCodeOutputDoneTimer() + if (shiftEnterReconfirmTimer !== null) { + clearTimeout(shiftEnterReconfirmTimer) + shiftEnterReconfirmTimer = null + } // Why: actively resolve any in-flight passphrase-gate waits so their // zustand subscribers + async IIFEs don't hang for the rest of the // session when the pane is torn down before SSH state changes. @@ -7238,6 +7390,9 @@ export function connectPanePty( pendingGeometryReportRaf = null } commandLifecycle.dispose() + deferredCommandFinishedStatusDrop = null + visibleForegroundSamplePending = false + visibleForegroundSampleSettled = false paneForegroundAgentTracker.dispose() agentCompletionCoordinator.dispose() } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index e5ab8c38cf3..40892001963 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -36,6 +36,7 @@ export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string } export type PtyConnectResult = { id: string + launchAgent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig snapshot?: string snapshotCols?: number diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 6a9e8c41bd6..fd2663b5a33 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1229,6 +1229,7 @@ describe('createIpcPtyTransport', () => { const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-reattach', isReattach: true, + launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, snapshotRows: 43 @@ -1266,6 +1267,7 @@ describe('createIpcPtyTransport', () => { expect(result).toEqual({ id: 'pty-reattach', + launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, snapshotRows: 43, @@ -1276,6 +1278,30 @@ describe('createIpcPtyTransport', () => { }) }) + it('drops an unknown daemon launch identity from the connection result', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + spawn.mockResolvedValueOnce({ + id: 'pty-unknown-launch-agent', + isReattach: true, + launchAgent: 'not-an-agent' + }) + + const result = await createIpcPtyTransport({}).connect({ url: '', callbacks: {} }) + + expect(result).toEqual({ + id: 'pty-unknown-launch-agent', + snapshot: undefined, + snapshotCols: undefined, + snapshotRows: undefined, + isAlternateScreen: undefined, + sessionExpired: undefined, + coldRestore: undefined, + replay: undefined, + pendingEscapeTailAnsi: undefined + }) + }) + it('threads the daemon pendingEscapeTailAnsi through the reattach connect result (#7329)', async () => { // Why: the local daemon ships the mid-escape tail on the spawn/reattach // result; dropping it here silently regressed the local half of #7329 diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 0aacaf2e446..70a6071b0e6 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -36,6 +36,7 @@ import { type ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { isTuiAgent } from '../../../../shared/tui-agent-config' // Re-export public API so existing consumers keep working. export { @@ -725,6 +726,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(telemetry ? { telemetry } : {}) }) const spawnResult = result as PtyConnectResult & { isReattach?: boolean } + const resultLaunchAgent = isTuiAgent(spawnResult.launchAgent) + ? spawnResult.launchAgent + : undefined // If destroyed while spawn was in flight, kill the new pty and bail if (destroyed) { @@ -754,6 +758,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { id: spawnResult.id, + ...(resultLaunchAgent ? { launchAgent: resultLaunchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), snapshot: spawnResult.snapshot, snapshotCols: spawnResult.snapshotCols, @@ -765,9 +770,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi } satisfies PtyConnectResult } - if (spawnResult.launchConfig || spawnResult.startupCwdFallback) { + if (resultLaunchAgent || spawnResult.launchConfig || spawnResult.startupCwdFallback) { return { id: spawnResult.id, + ...(resultLaunchAgent ? { launchAgent: resultLaunchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index a92e03cce8c..3d5108da618 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -49,7 +49,7 @@ function splitLayout(): TerminalLayoutSnapshot { } } -function createTerminalTab(id: string, ptyId: string | null): TerminalTab { +function createTerminalTab(id: string, ptyId: string | null, shellOverride?: string): TerminalTab { return { id, ptyId, @@ -59,13 +59,15 @@ function createTerminalTab(id: string, ptyId: string | null): TerminalTab { customTitle: null, color: null, sortOrder: 1, - createdAt: 1 + createdAt: 1, + ...(shellOverride !== undefined ? { shellOverride } : {}) } } function createStore( layout: TerminalLayoutSnapshot = splitLayout(), - targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2], + sourceShellOverride = 'powershell.exe' ): TerminalPaneTabDetachStore { const store = { createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { @@ -107,6 +109,9 @@ function createStore( } }), syncPaneDetachPtyOwnership: vi.fn(), + tabsByWorktree: { + [WORKTREE_ID]: [createTerminalTab(SOURCE_TAB_ID, 'pty-left', sourceShellOverride)] + }, terminalLayoutsByTabId: { [SOURCE_TAB_ID]: layout } @@ -242,7 +247,7 @@ describe('detachTerminalPaneToTab', () => { expect(result?.ptyId).toBe('remote:env-1@@terminal-1') expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) - expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', { activate: true, initialPtyId: 'remote:env-1@@terminal-1', recordInteraction: true @@ -262,6 +267,7 @@ describe('detachTerminalPaneToTab', () => { titlesByLeafId: { [LEAF_2]: 'remote shell' } }) expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedLeafId: LEAF_2, detachedPtyId: 'remote:env-1@@terminal-1', sourceLayout: { root: { type: 'leaf', leafId: LEAF_1 }, @@ -277,6 +283,35 @@ describe('detachTerminalPaneToTab', () => { expect(persistLayoutSnapshot).toHaveBeenCalled() }) + it.each(['powershell.exe', 'wsl.exe'])( + 'preserves the moved PTY shell override when the source uses %s', + (shellOverride) => { + const store = createStore(splitLayout(), [EXISTING_TAB_1], shellOverride) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_1), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.createTab).toHaveBeenCalledWith( + WORKTREE_ID, + TARGET_GROUP_ID, + shellOverride, + expect.objectContaining({ initialPtyId: 'pty-left' }) + ) + } + ) + it('syncs PTY ownership when the primary source pane is detached', () => { const store = createStore() const manager = { @@ -305,6 +340,7 @@ describe('detachTerminalPaneToTab', () => { titlesByLeafId: { [LEAF_2]: 'remote shell' } }) expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedLeafId: LEAF_1, detachedPtyId: 'pty-left', sourceLayout: { root: { type: 'leaf', leafId: LEAF_2 }, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 26f3d5996e3..7cbc9c095f1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -14,6 +14,7 @@ export type TerminalPaneTabDetachStore = Pick< | 'setActiveTabType' | 'setTabLayout' | 'syncPaneDetachPtyOwnership' + | 'tabsByWorktree' | 'terminalLayoutsByTabId' > @@ -278,7 +279,10 @@ export function detachTerminalPaneToTab(args: { } const latestStore = args.getStore() - const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + const sourceShellOverride = latestStore.tabsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.sourceTabId + )?.shellOverride + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, { activate: true, initialPtyId: ptyId ?? undefined, recordInteraction: true @@ -294,6 +298,7 @@ export function detachTerminalPaneToTab(args: { afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) afterCreateStore.setTabLayout(tab.id, detachedLayout) afterCreateStore.syncPaneDetachPtyOwnership({ + detachedLeafId: sourceLeafId, detachedPtyId: ptyId, sourceLayout: detached.sourceLayout, sourceTabId: args.sourceTabId, diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 8bf8159d9d1..a9929951449 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveTerminalShortcutAction, type TerminalShortcutEvent @@ -89,7 +89,9 @@ describe('resolveTerminalShortcutAction', () => { }) }) - it('uses the Codex-compatible Shift+Enter sequence on Windows', () => { + it('uses the Codex-compatible Shift+Enter sequence on Windows win32-input-mode panes', () => { + // Default and explicit legacy encodings both keep Codex-on-PowerShell + // newlining instead of ignoring the chord. expect( resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', shiftKey: true }), @@ -102,6 +104,140 @@ describe('resolveTerminalShortcutAction', () => { type: 'sendInput', data: '\x1b\r' }) + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + undefined, + undefined, + undefined, + () => 'alt-enter' + ) + ).toEqual({ type: 'sendInput', data: '\x1b\r' }) + }) + + it('sends CSI-u Shift+Enter to Windows panes whose active agent requires it (#7620)', () => { + // Why: droid parses CSI-u directly and treats the Alt+Enter byte as a plain + // Enter that submits, so its pane capability must produce `\x1b[13;2u`. + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + undefined, + undefined, + undefined, + () => 'csi-u' + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + }) + + it('preserves the Windows fallback for SSH, WSL, and remote panes', () => { + // Why: a Windows client cannot safely infer a remote peer's decoder, so the + // local Droid exception must not broaden main's existing remote behavior. + const isLocalWindowsConptyPane = vi.fn(() => false) + const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + for (let index = 0; index < 2; index += 1) { + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + isLocalWindowsConptyPane, + undefined, + undefined, + getWindowsShiftEnterEncoding + ) + ).toEqual({ type: 'sendInput', data: '\x1b\r' }) + } + expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2) + expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() + }) + + it('always uses CSI-u Shift+Enter off Windows regardless of Windows encoding', () => { + for (const encoding of [() => 'csi-u' as const, () => 'alt-enter' as const, undefined]) { + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + false, + undefined, + undefined, + undefined, + undefined, + encoding + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + } + }) + + it('keeps ConPTY and agent lookups off unrelated keystrokes', () => { + const isLocalWindowsConptyPane = vi.fn(() => true) + const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + + expect( + resolveTerminalShortcutAction( + event({ key: 'a', code: 'KeyA' }), + false, + 'false', + 0, + true, + undefined, + isLocalWindowsConptyPane, + undefined, + undefined, + getWindowsShiftEnterEncoding + ) + ).toBeNull() + expect(isLocalWindowsConptyPane).not.toHaveBeenCalled() + expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() + + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + isLocalWindowsConptyPane, + undefined, + undefined, + getWindowsShiftEnterEncoding + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(1) + expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1) + + isLocalWindowsConptyPane.mockReturnValue(false) + expect( + resolveTerminalShortcutAction( + event({ key: 'Enter', code: 'Enter', shiftKey: true }), + false, + 'false', + 0, + true, + undefined, + isLocalWindowsConptyPane, + undefined, + undefined, + getWindowsShiftEnterEncoding + ) + ).toEqual({ type: 'sendInput', data: '\x1b\r' }) + expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2) + expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1) }) it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 69bc914d048..f0f284d167f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' +import type { WindowsShiftEnterEncoding } from './terminal-windows-shift-enter' export type TerminalShortcutEvent = { key: string @@ -75,9 +76,8 @@ export function resolveTerminalShortcutAction( isWindows: boolean = false, keybindings?: KeybindingOverrides, // Why: lazily reports whether the active pane is a local native Windows - // ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow - // word-nav rule below, so the execution-host lookup it performs stays off the - // hot path for every other keystroke. + // ConPTY. Only consulted for Shift+Enter and Ctrl+Arrow, so execution-host + // lookup stays off every other keystroke. isLocalWindowsConptyPane?: () => boolean, // Why: lazily reports whether the active pane's application has enabled the // kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation @@ -87,7 +87,10 @@ export function resolveTerminalShortcutAction( // layout; the physical-code table above is US QWERTY and reports the wrong // key on Dvorak/Colemak/AZERTY-class layouts. This resolves through // Chromium's KeyboardLayoutMap when it is available. - layoutBaseCharacterForCode?: (code: string) => string | undefined + layoutBaseCharacterForCode?: (code: string) => string | undefined, + // Why: lazily resolves the active pane's Windows encoding. Only consulted for + // Shift+Enter so agent-state lookup stays off every other keystroke. + getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -147,9 +150,15 @@ export function resolveTerminalShortcutAction( event.shiftKey && event.key === 'Enter' ) { - // Why: Codex on Windows PowerShell treats CSI-u Shift+Enter as inert, - // while the Alt+Enter byte path inserts a composer newline. - return { type: 'sendInput', data: isWindows ? '\x1b\r' : '\x1b[13;2u' } + // Why: Droid needs CSI-u but Codex needs Esc+CR; preserve legacy bytes for + // SSH/WSL/remote peers that cannot be safely classified from this client. + const useLocalWindowsCapability = isWindows && isLocalWindowsConptyPane?.() !== false + const encoding = useLocalWindowsCapability + ? (getWindowsShiftEnterEncoding?.() ?? 'alt-enter') + : isWindows + ? 'alt-enter' + : 'csi-u' + return { type: 'sendInput', data: encoding === 'csi-u' ? '\x1b[13;2u' : '\x1b\r' } } if ( diff --git a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts new file mode 100644 index 00000000000..d19059b8e13 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + resolveWindowsShiftEnterEncoding, + resolveWindowsShiftEnterEncodingForPane +} from './terminal-windows-shift-enter' + +describe('resolveWindowsShiftEnterEncoding', () => { + it('uses CSI-u only for trusted Droid process evidence', () => { + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent: 'droid', routingTrusted: true, shellForeground: false } + }) + ).toBe('csi-u') + expect(resolveWindowsShiftEnterEncoding({ launchAgentType: 'droid' })).toBe('alt-enter') + }) + + it('does not let hook or OSC-derived status forge Droid input routing', () => { + const state = { + paneForegroundAgentByPaneKey: {}, + agentStatusByPaneKey: { + 'tab:pane': { agentType: 'droid' as const } + }, + agentLaunchConfigByPaneKey: {} + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:pane')).toBe('alt-enter') + }) + + it('keeps the legacy byte for Codex, Antigravity, unknown, and plain panes', () => { + for (const agent of ['codex', 'antigravity', 'claude', null] as const) { + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent, shellForeground: false } + }) + ).toBe('alt-enter') + } + expect(resolveWindowsShiftEnterEncoding({})).toBe('alt-enter') + }) + + it('lets current process identity override stale launch ownership', () => { + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent: 'antigravity', routingTrusted: true, shellForeground: false }, + launchAgentType: 'droid' + }) + ).toBe('alt-enter') + }) + + it('fails closed while a newer command generation awaits trusted evidence', () => { + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent: 'droid', shellForeground: false }, + launchAgentType: 'droid' + }) + ).toBe('alt-enter') + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent: null, shellForeground: false }, + launchAgentType: 'droid' + }) + ).toBe('alt-enter') + }) + + it('keeps launch ownership on its original leaf after a split sibling survives', () => { + const state = { + paneForegroundAgentByPaneKey: {}, + agentLaunchConfigByPaneKey: { + 'tab:launched-droid': { identity: { agentType: 'droid' } } + } + } + + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:launched-droid')).toBe('alt-enter') + // Why: after split→close leaves only the sibling, pane count is no longer + // ownership evidence; the surviving leaf must keep the legacy fallback. + expect(resolveWindowsShiftEnterEncodingForPane(state, 'tab:surviving-sibling')).toBe( + 'alt-enter' + ) + }) + + it('clears stale Droid ownership after the foreground returns to the shell', () => { + expect( + resolveWindowsShiftEnterEncoding({ + foreground: { agent: null, shellForeground: true }, + launchAgentType: 'droid' + }) + ).toBe('alt-enter') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts new file mode 100644 index 00000000000..4f55f13b943 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts @@ -0,0 +1,43 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +export type WindowsShiftEnterEncoding = 'alt-enter' | 'csi-u' + +type WindowsShiftEnterAgentSignals = { + foreground?: PaneForegroundAgentEntry + launchAgentType?: AgentType +} + +type WindowsShiftEnterPaneState = { + paneForegroundAgentByPaneKey: Record + agentLaunchConfigByPaneKey: Record +} + +/** Resolve without key-path PTY I/O; current process/shell evidence overrides + * launch ownership. Hook status is excluded because PTY output can forge it. */ +export function resolveWindowsShiftEnterEncoding( + signals: WindowsShiftEnterAgentSignals +): WindowsShiftEnterEncoding { + if (signals.foreground?.shellForeground) { + return 'alt-enter' + } + // Why: an entry marks a newer command/process generation. Until fresh + // confirmation trusts it, stale launch ownership must not route input. + // Launch metadata is only an expectation used to start confirmation; it is + // never byte-routing authority because warm/stale daemon state can outlive + // the process that originally launched the agent. + const agent = signals.foreground?.routingTrusted === true ? signals.foreground.agent : null + return agent ? (TUI_AGENT_CONFIG[agent].windowsShiftEnterEncoding ?? 'alt-enter') : 'alt-enter' +} + +/** Resolves only pane-keyed evidence so a split sibling cannot inherit tab ownership. */ +export function resolveWindowsShiftEnterEncodingForPane( + state: WindowsShiftEnterPaneState, + paneKey: string +): WindowsShiftEnterEncoding { + return resolveWindowsShiftEnterEncoding({ + foreground: state.paneForegroundAgentByPaneKey[paneKey], + launchAgentType: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index b0e2cd10ef8..372a222e9ac 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1872,6 +1872,28 @@ export function useTerminalPaneLifecycle({ // eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility and terminal identity changes must refresh existing PTY process tracking even though the ref object identity is stable. }, [cwd, isVisible, isVisibleRef, panePtyBindingsRef, tabId]) + useEffect(() => { + if (!isActive || !isVisible || typeof window === 'undefined') { + return + } + const onWindowFocus = (): void => { + const activePane = managerRef.current?.getActivePane() + if (!activePane) { + return + } + const binding = panePtyBindingsRef.current.get(activePane.id) as + | (IDisposable & { sampleForegroundAgentOnFocus?: () => void }) + | undefined + // Why: window refocus does not change the active leaf, so the pane + // manager's onActivePaneChange hook is not fired. Re-sample the same + // pane to revoke stale launch-only identity and confirm current Droid + // ownership before the next Windows Shift+Enter. + binding?.sampleForegroundAgentOnFocus?.() + } + window.addEventListener('focus', onWindowFocus) + return () => window.removeEventListener('focus', onWindowFocus) + }, [isActive, isVisible, managerRef, panePtyBindingsRef]) + useEffect(() => { const manager = managerRef.current if (!manager || !settings) { diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index c779c05b018..4349b25e2ca 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -5,6 +5,8 @@ import type { TuiAgent } from '../../../../shared/types' export type PaneForegroundAgentEntry = { /** Recognized agent process in the pane's foreground; null when unknown. */ agent: TuiAgent | null + /** True only when fresh provider evidence is safe for input-byte routing. */ + routingTrusted?: boolean /** True once the foreground is proven back at the shell (OSC 133;D) — * process-grade launched-agent exit evidence, independent of titles. */ shellForeground: boolean @@ -38,6 +40,7 @@ export const createPaneForegroundAgentSlice: StateCreator< if ( current && current.agent === entry.agent && + current.routingTrusted === entry.routingTrusted && current.shellForeground === entry.shellForeground ) { return s diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index bb0043b3adb..a7cbac5cca2 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1271,6 +1271,7 @@ describe('setActiveWorktree', () => { }) store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: '11111111-1111-4111-8111-111111111111', detachedPtyId: 'pty-detached', sourceLayout: { root: { type: 'leaf', leafId: 'survivor-leaf' }, diff --git a/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts new file mode 100644 index 00000000000..5afdcb6fe43 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter' +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +describe('syncPaneDetachPtyOwnership agent identity', () => { + it('moves process and launch identity to the detached leaf without forging hook identity', () => { + const store = createTestStore() + const worktreeId = 'repo::/repo/worktree' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + const detachedLeafId = '11111111-1111-4111-8111-111111111111' + const siblingLeafId = '22222222-2222-4222-8222-222222222222' + const sourcePaneKey = `${sourceTabId}:${detachedLeafId}` + const targetPaneKey = `${targetTabId}:${detachedLeafId}` + const siblingPaneKey = `${sourceTabId}:${siblingLeafId}` + + seedStore(store, { + worktreesByRepo: { + repo: [makeWorktree({ id: worktreeId, repoId: 'repo', path: '/repo/worktree' })] + }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: sourceTabId, worktreeId, ptyId: 'pty-droid' }), + makeTab({ id: targetTabId, worktreeId, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-droid', 'pty-sibling'], + [targetTabId]: [] + } + }) + store.getState().setPaneForegroundAgent(sourcePaneKey, { + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + store + .getState() + .setPaneForegroundAgent(siblingPaneKey, { agent: 'antigravity', shellForeground: false }) + store + .getState() + .registerAgentLaunchConfig( + sourcePaneKey, + { agentArgs: '', agentEnv: {} }, + { agentType: 'droid', tabId: sourceTabId, leafId: detachedLeafId } + ) + store.getState().setAgentStatus(sourcePaneKey, { + state: 'working', + prompt: '', + agentType: 'droid' + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId, + detachedPtyId: 'pty-droid', + sourceLayout: { + root: { type: 'leaf', leafId: siblingLeafId }, + activeLeafId: siblingLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { [siblingLeafId]: 'pty-sibling' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.paneForegroundAgentByPaneKey[sourcePaneKey]).toBeUndefined() + expect(state.paneForegroundAgentByPaneKey[targetPaneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(state.agentLaunchConfigByPaneKey[sourcePaneKey]).toBeUndefined() + expect(state.agentLaunchConfigByPaneKey[targetPaneKey]?.identity).toMatchObject({ + agentType: 'droid', + tabId: targetTabId, + leafId: detachedLeafId + }) + // Why: the PTY keeps emitting its immutable source ORCA_PANE_KEY. A copied + // target hook row would look live but could never receive later updates. + expect(state.agentStatusByPaneKey[sourcePaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBe(true) + expect(state.paneForegroundAgentByPaneKey[siblingPaneKey]).toEqual({ + agent: 'antigravity', + shellForeground: false + }) + expect(resolveWindowsShiftEnterEncodingForPane(state, targetPaneKey)).toBe('csi-u') + expect(resolveWindowsShiftEnterEncodingForPane(state, siblingPaneKey)).toBe('alt-enter') + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 77d93f75d19..514f5b01e7c 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -28,7 +28,11 @@ import { } from '../../../../shared/workspace-scope' import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' import { isWslUncPath } from '../../../../shared/wsl-paths' @@ -647,6 +651,7 @@ export type TerminalSlice = { setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void syncPaneDetachPtyOwnership: (args: { + detachedLeafId: string detachedPtyId: string | null sourceLayout: TerminalLayoutSnapshot sourceTabId: string @@ -2710,7 +2715,13 @@ export const createTerminalSlice: StateCreator }) }, - syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + syncPaneDetachPtyOwnership: ({ + detachedLeafId, + detachedPtyId, + sourceLayout, + sourceTabId, + targetTabId + }) => { set((s) => { const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( @@ -2739,8 +2750,8 @@ export const createTerminalSlice: StateCreator nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId } - // Why: pane-to-tab detach moves live PTY ownership without spawning or - // exiting processes, so sync identity maps directly without activity bumps. + // Why: pane-to-tab detach moves a live PTY without spawning or exiting, + // so transfer its safe pane-owned identity without activity bumps. const sourceTabsByWorktree = withTerminalTabPtyId( s.tabsByWorktree, sourceTabId, @@ -2750,10 +2761,60 @@ export const createTerminalSlice: StateCreator ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) : sourceTabsByWorktree + const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId) + const targetPaneKey = makePaneKey(targetTabId, detachedLeafId) + const sourceForeground = s.paneForegroundAgentByPaneKey[sourcePaneKey] + const sourceLaunchConfig = s.agentLaunchConfigByPaneKey[sourcePaneKey] + const hadSourceHookStatus = sourcePaneKey in s.agentStatusByPaneKey + let nextPaneForegroundAgentByPaneKey = s.paneForegroundAgentByPaneKey + let nextAgentLaunchConfigByPaneKey = s.agentLaunchConfigByPaneKey + let nextAgentStatusByPaneKey = s.agentStatusByPaneKey + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (sourceForeground) { + nextPaneForegroundAgentByPaneKey = { ...s.paneForegroundAgentByPaneKey } + delete nextPaneForegroundAgentByPaneKey[sourcePaneKey] + nextPaneForegroundAgentByPaneKey[targetPaneKey] = sourceForeground + } + if (sourceLaunchConfig) { + nextAgentLaunchConfigByPaneKey = { ...s.agentLaunchConfigByPaneKey } + delete nextAgentLaunchConfigByPaneKey[sourcePaneKey] + nextAgentLaunchConfigByPaneKey[targetPaneKey] = { + ...sourceLaunchConfig, + identity: { + ...sourceLaunchConfig.identity, + tabId: targetTabId, + leafId: detachedLeafId + } + } + } + if (hadSourceHookStatus) { + nextAgentStatusByPaneKey = { ...s.agentStatusByPaneKey } + delete nextAgentStatusByPaneKey[sourcePaneKey] + nextRetentionSuppressedPaneKeys = { + ...s.retentionSuppressedPaneKeys, + [sourcePaneKey]: true + } + } + return { ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), + ...(nextPaneForegroundAgentByPaneKey !== s.paneForegroundAgentByPaneKey + ? { paneForegroundAgentByPaneKey: nextPaneForegroundAgentByPaneKey } + : {}), + ...(nextAgentLaunchConfigByPaneKey !== s.agentLaunchConfigByPaneKey + ? { agentLaunchConfigByPaneKey: nextAgentLaunchConfigByPaneKey } + : {}), + ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey + ? { + // Why: the PTY keeps its immutable source ORCA_PANE_KEY; retire + // rather than re-key a hook row that future events cannot update. + agentStatusByPaneKey: nextAgentStatusByPaneKey, + agentStatusEpoch: s.agentStatusEpoch + 1, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys + } + : {}) } }) }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index be642916a6e..e477f5a00d0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2729,6 +2729,8 @@ function createPtyApi(): NonNullable['pty']> { publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), + // Why: paired web panes cannot provide a local post-boundary process scan. + confirmForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), getSize: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 297e8e405f9..11e11fe2f53 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -25,6 +25,13 @@ describe('agent process recognition', () => { expect(isExpectedAgentProcess('/usr/local/bin/openclaude', 'claude')).toBe(false) }) + it('recognizes the Droid foreground process on Windows', () => { + expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\droid.cmd`)).toEqual({ + agent: 'droid', + processName: 'droid' + }) + }) + it('matches expected agents from platform-specific foreground process paths', () => { expect(recognizeAgentProcess('claude')).toEqual({ agent: 'claude', diff --git a/src/shared/process-table-snapshot.test.ts b/src/shared/process-table-snapshot.test.ts index f1a5df2be9f..e9ec86bb3f5 100644 --- a/src/shared/process-table-snapshot.test.ts +++ b/src/shared/process-table-snapshot.test.ts @@ -121,6 +121,58 @@ describe('process-table-snapshot reader', () => { expect(scans).toBe(2) }) + it('forces a post-request scan even when a same-tick cache exists', async () => { + let scans = 0 + const reader = createProcessTableSnapshotReader({ + runPs: async () => `scan-${++scans}`, + now: () => 0 + }) + + expect(await reader.getSnapshot()).toBe('scan-1') + expect(await reader.getFreshSnapshot()).toBe('scan-2') + expect(scans).toBe(2) + }) + + it('shares same-turn fresh requests but queues one scan after a pre-existing scan', async () => { + let scans = 0 + const first = deferred() + const second = deferred() + const reader = createProcessTableSnapshotReader({ + runPs: () => { + scans += 1 + return scans === 1 ? first.promise : second.promise + }, + now: () => 0 + }) + + const stale = reader.getSnapshot() + const freshA = reader.getFreshSnapshot() + const freshB = reader.getFreshSnapshot() + expect(scans).toBe(1) + first.resolve('stale') + expect(await stale).toBe('stale') + await Promise.resolve() + expect(scans).toBe(2) + second.resolve('fresh') + expect(await freshA).toBe('fresh') + expect(await freshB).toBe('fresh') + expect(scans).toBe(2) + }) + + it('does not let an ordinary same-turn miss race a queued fresh scan', async () => { + let scans = 0 + const reader = createProcessTableSnapshotReader({ + runPs: async () => `scan-${++scans}`, + now: () => 0 + }) + + const fresh = reader.getFreshSnapshot() + const ordinary = reader.getSnapshot() + + await expect(Promise.all([fresh, ordinary])).resolves.toEqual(['scan-1', 'scan-1']) + expect(scans).toBe(1) + }) + it('shares one parsed-rows array across a burst so panes do not each re-parse', async () => { // Mirrors the POSIX default reader: runPs parses inside the deduped scan, so // every caller in the TTL window gets the SAME ProcessTableRow[] instance diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index bb6ce561db8..9827d1e8523 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -70,19 +70,16 @@ export function createProcessTableSnapshotReader( deps: ProcessTableSnapshotReaderDeps ): { getSnapshot: () => Promise + getFreshSnapshot: () => Promise reset: () => void } { const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS let cached: Snapshot | null = null let inFlight: Promise | null = null + let sequence = 0 + let freshQueued: { promise: Promise; startSequence: number | null } | null = null - async function getSnapshot(): Promise { - if (cached && deps.now() - cached.capturedAtMs < ttlMs) { - return cached.value - } - if (inFlight) { - return inFlight - } + async function runSnapshot(): Promise { const promise = deps.runPs() inFlight = promise try { @@ -92,22 +89,78 @@ export function createProcessTableSnapshotReader( cached = { value, capturedAtMs: deps.now() } return value } finally { - // Clear in-flight on success and failure so a transient `ps` error - // (timeout, nonzero exit) retries on the next call instead of being - // cached; callers keep their existing best-effort fall-through. if (inFlight === promise) { inFlight = null } } } + async function getSnapshot(): Promise { + if (cached && deps.now() - cached.capturedAtMs < ttlMs) { + return cached.value + } + if (inFlight) { + return inFlight + } + if (freshQueued) { + // Why: a fresh request schedules its scan in a microtask so same-turn + // callers can share it; an ordinary miss must not start a competing scan. + return freshQueued.promise + } + return runSnapshot() + } + + function getFreshSnapshot(): Promise { + const requestSequence = ++sequence + if (freshQueued?.startSequence === null) { + return freshQueued.promise + } + const priorFresh = freshQueued?.promise ?? null + const priorScan = inFlight + const entry: { promise: Promise; startSequence: number | null } = { + promise: Promise.resolve(undefined as never), + startSequence: null + } + entry.promise = Promise.resolve().then(async () => { + for (const prior of [priorFresh, priorScan]) { + if (!prior) { + continue + } + try { + await prior + } catch { + // The post-boundary scan below owns the confirmation result. + } + } + // Why: same-turn callers join while startSequence is null; later callers + // queue behind this scan. The sequence proves every shared scan began + // strictly after each request without relying on wall-clock precision. + entry.startSequence = ++sequence + if (entry.startSequence <= requestSequence) { + throw new Error('fresh process snapshot did not start after request') + } + return runSnapshot() + }) + freshQueued = entry + const clearQueued = (): void => { + if (freshQueued === entry) { + freshQueued = null + } + } + void entry.promise.then(clearQueued, clearQueued) + return entry.promise + } + return { getSnapshot, + getFreshSnapshot, // Why: lets tests that mock `ps` per case clear the cross-call cache so one // case's snapshot can't satisfy the next within the TTL window. reset: () => { cached = null inFlight = null + sequence = 0 + freshQueued = null } } } @@ -135,6 +188,11 @@ export function getProcessTableSnapshot(): Promise { return defaultReader.getSnapshot() } +/** Capture process rows from a scan that starts after this request. */ +export function getFreshProcessTableSnapshot(): Promise { + return defaultReader.getFreshSnapshot() +} + /** * Test-only: clear the shared snapshot cache so suites that mock `ps` between * cases don't have one case's snapshot served to the next within the TTL. diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ff4427bb5da..29a03d14bc4 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -63,6 +63,9 @@ export type TuiAgentConfig = { * `›` prompt only when the composer row exists, so Orca can paste as soon * as that prompt appears after bracketed paste is enabled. */ draftPasteReadySignal?: DraftPasteReadySignal + /** Windows Shift+Enter override. Omitted agents keep the legacy Esc+CR path + * because the renderer cannot infer every local or remote TUI's decoder. */ + windowsShiftEnterEncoding?: 'csi-u' } export const TUI_AGENT_CONFIG: Record = { @@ -281,7 +284,10 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'droid', launchCmd: 'droid', expectedProcess: 'droid', - promptInjectionMode: 'argv' + promptInjectionMode: 'argv', + // Why: Droid decodes CSI-u on Windows and treats Orca's legacy Esc+CR + // fallback as plain Enter, which submits instead of inserting a newline. + windowsShiftEnterEncoding: 'csi-u' }, kimi: { detectCmd: 'kimi', diff --git a/tests/e2e/terminal-foreground-confirmation.unit.test.ts b/tests/e2e/terminal-foreground-confirmation.unit.test.ts new file mode 100644 index 00000000000..cede865696c --- /dev/null +++ b/tests/e2e/terminal-foreground-confirmation.unit.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, resolveForegroundMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + resolveForegroundMock: vi.fn() +})) + +vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../../src/main/pwsh', () => ({ isPwshAvailable: vi.fn(() => false) })) +vi.mock('../../src/main/providers/windows-powershell-executable', () => ({ + resolveWindowsPowerShellExecutablePath: () => + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + resolveWindowsPowerShellSpawnChain: () => [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + ], + getWindowsCmdPath: () => 'C:\\Windows\\System32\\cmd.exe' +})) +vi.mock('../../src/main/providers/agent-foreground-process', () => ({ + resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => { + const value = await resolveForegroundMock(...args) + return value && typeof value === 'object' && 'available' in value + ? value + : { available: true, processName: value } + } +})) + +import { createPtySubprocess } from '../../src/main/daemon/pty-subprocess' +import { createPaneForegroundAgentTracker } from '../../src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker' + +function mockWindowsPty() { + const exitListeners: ((event: { exitCode: number }) => void)[] = [] + return { + pid: 12345, + process: 'powershell.exe', + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => { + exitListeners.push(callback) + return { dispose: vi.fn() } + }) + } +} + +describe('daemon foreground confirmation composes with pane tracking', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + vi.useFakeTimers() + spawnMock.mockReset() + resolveForegroundMock.mockReset() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + + afterEach(() => { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + function createComposedTracker(publish: ReturnType) { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: () => handle.confirmForegroundProcess!(), + publish, + hasKnownAgentIdentity: () => true + }) + return { handle, tracker } + } + + it('keeps a restored Droid through a scan resolving after the old cache window', async () => { + spawnMock.mockReturnValue(mockWindowsPty()) + let resolveFresh!: (value: string) => void + resolveForegroundMock.mockReturnValue( + new Promise((resolve) => { + resolveFresh = resolve + }) + ) + const publish = vi.fn() + const { tracker } = createComposedTracker(publish) + + tracker.onVisiblePtyBound(true) + await vi.advanceTimersByTimeAsync(350) + await vi.advanceTimersByTimeAsync(2_000) + expect(publish).not.toHaveBeenCalled() + resolveFresh('droid') + await vi.advanceTimersByTimeAsync(0) + + expect(publish).toHaveBeenCalledExactlyOnceWith({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveForegroundMock).toHaveBeenCalledTimes(1) + }) + + it('clears launch identity after one fresh scan confirms a real shell exit', async () => { + spawnMock.mockReturnValue(mockWindowsPty()) + resolveForegroundMock.mockResolvedValue('powershell.exe') + const publish = vi.fn() + const { tracker } = createComposedTracker(publish) + + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(350) + + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + expect(resolveForegroundMock).toHaveBeenCalledTimes(1) + }) + + it('fails closed without claiming shell when every fresh scan is unavailable', async () => { + spawnMock.mockReturnValue(mockWindowsPty()) + resolveForegroundMock.mockResolvedValue({ + available: false, + processName: 'powershell.exe' + }) + const publish = vi.fn() + const { tracker } = createComposedTracker(publish) + + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(350 + 1_200 + 6_000) + + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(resolveForegroundMock).toHaveBeenCalledTimes(3) + }) +}) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index d0243f5cc03..c6c40afe6fc 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -24,7 +24,8 @@ import { waitForTerminalOutput, waitForPaneCount, getTerminalContent, - waitForActivePanePtyId + waitForActivePanePtyId, + focusActiveTerminalInput } from './helpers/terminal' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' @@ -82,14 +83,34 @@ async function getPtyWrites(app: ElectronApplication): Promise { }) } -// Why: the window-level keydown handler is gated on non-editable targets; the -// xterm helper textarea is treated as non-editable on purpose. Focusing it -// guarantees each chord reaches the shortcut policy through the real DOM path. -async function focusActiveTerminal(page: Page): Promise { - await page.evaluate(() => { - const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null - textarea?.focus() - }) +async function setActivePaneForegroundAgent( + page: Page, + agent: 'droid' | 'antigravity' | null +): Promise { + return page.evaluate((agent) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!state || !tabId || !pane) { + throw new Error('No active terminal pane for foreground-agent setup') + } + const paneKey = `${tabId}:${pane.leafId}` + state.setPaneForegroundAgent(paneKey, { + agent, + shellForeground: false, + // The shortcut only emits CSI-u for a process identity confirmed to + // belong to this PTY; keep the fixture aligned with that trust gate. + routingTrusted: agent === 'droid' + }) + return paneKey + }, agent) } async function dispatchCtrlCToActiveTerminalTextarea( @@ -416,22 +437,25 @@ async function pressAndExpectWrite( page: Page, app: ElectronApplication, chord: string, - expectedData: string + expectedData: string, + repetitions = 1 ): Promise { await clearPtyWriteLog(app) - await focusActiveTerminal(page) - await page.keyboard.press(chord) + await focusActiveTerminalInput(page) + for (let index = 0; index < repetitions; index++) { + await page.keyboard.press(chord) + } // Why: assert exact equality, not substring match. Short control codes like // \x01 (Ctrl+A) and \x05 (Ctrl+E) are single bytes that can appear inside // unrelated writes (shell prompt redraws, bracketed-paste sequences), so a // substring match would produce false positives. await expect - .poll(async () => (await getPtyWrites(app)).some((w) => w === expectedData), { + .poll(async () => (await getPtyWrites(app)).filter((write) => write === expectedData).length, { timeout: 5_000, message: `Expected chord "${chord}" to write ${JSON.stringify(expectedData)}` }) - .toBe(true) + .toBeGreaterThanOrEqual(repetitions) } const isMac = process.platform === 'darwin' @@ -447,7 +471,9 @@ const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d' // Why: a freshly split pane can transiently still report a running child, so // poll for the confirm dialog and pane-count settling instead of a fixed wait. async function closeActivePaneAndSettle(page: Page, expectedCount: number): Promise { - await focusActiveTerminal(page) + // Why: split panes own multiple textareas; the shared helper focuses the + // PaneManager's active terminal instead of whichever appears first in the DOM. + await focusActiveTerminalInput(page) await page.keyboard.press(`${mod}+w`) // The "Stop running command?" confirm surfaces a "Stop and Close" action when // the pane still reports a running child. @@ -507,6 +533,26 @@ test.describe('Terminal Shortcuts', () => { ) }) + test('Droid gets CSI-u Shift+Enter on Windows without changing Antigravity', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform !== 'win32', 'Windows ConPTY encoding contract') + await installMainProcessPtyWriteSpy(electronApp) + await waitForActivePanePtyId(orcaPage) + const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') + try { + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u', 2) + await setActivePaneForegroundAgent(orcaPage, 'antigravity') + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') + } finally { + await orcaPage.evaluate( + (key) => window.__store?.getState().clearPaneForegroundAgent(key), + paneKey + ) + } + }) + test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ orcaPage, electronApp @@ -525,7 +571,7 @@ test.describe('Terminal Shortcuts', () => { await waitForActivePanePtyId(orcaPage) await enableKittyKeyboardReporting(orcaPage, 31) await clearPtyWriteLog(electronApp) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.down('Control') await orcaPage.keyboard.up('Control') expect((await getPtyWrites(electronApp)).join('')).toBe('') @@ -554,7 +600,7 @@ test.describe('Terminal Shortcuts', () => { .toBe(0) await clearPtyWriteLog(electronApp) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.type('x') await expect .poll(async () => (await getPtyWrites(electronApp)).some((write) => write === 'x'), { @@ -723,8 +769,8 @@ test.describe('Terminal Shortcuts', () => { // Ctrl+Backspace → \x17 (unix-word-rubout). await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17') - // Shift+Enter → a modified Enter byte path so agents can distinguish it - // from plain Enter. Windows uses Esc+CR because Codex ignores CSI-u there. + // Shift+Enter stays distinct; Windows keeps Esc+CR unless the active agent + // explicitly requires CSI-u (currently Droid, #7620). await pressAndExpectWrite( orcaPage, electronApp, @@ -747,7 +793,7 @@ test.describe('Terminal Shortcuts', () => { // --- action chords (no PTY byte; assert via visible effect) --- // Cmd/Ctrl+K clears the pane. - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+k`) await expect .poll(async () => (await getTerminalContent(orcaPage)).includes(marker), { @@ -758,7 +804,7 @@ test.describe('Terminal Shortcuts', () => { // Split vertically (chord varies by platform — see splitVerticalChord). const panesBeforeSplit = await countVisibleTerminalPanes(orcaPage) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(splitVerticalChord) await waitForPaneCount(orcaPage, panesBeforeSplit + 1) // Why: ensure the new split pane's PTY is actually bound before we later @@ -766,9 +812,9 @@ test.describe('Terminal Shortcuts', () => { await waitForActivePanePtyId(orcaPage) // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+BracketRight`) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+BracketLeft`) expect(await countVisibleTerminalPanes(orcaPage)).toBe(panesBeforeSplit + 1) @@ -784,12 +830,12 @@ test.describe('Terminal Shortcuts', () => { return state.expandedPaneByTabId[tabId] === true }) expect(await readExpanded()).toBe(false) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+Enter`) await expect .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not expand pane' }) .toBe(true) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+Shift+Enter`) await expect .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not collapse pane' }) @@ -800,14 +846,14 @@ test.describe('Terminal Shortcuts', () => { // Split horizontally (chord varies by platform — see splitHorizontalChord). const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(splitHorizontalChord) await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) await waitForActivePanePtyId(orcaPage) await closeActivePaneAndSettle(orcaPage, panesBeforeHSplit) // Cmd/Ctrl+F toggles the search overlay. - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press(`${mod}+f`) const searchInput = orcaPage.locator('[data-terminal-search-root] input').first() // Why: Escape is handled by TerminalSearch's React onKeyDown, which only @@ -849,7 +895,7 @@ test.describe('Terminal Shortcuts', () => { .toBe(true) await clearPtyWriteLog(electronApp) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press('Meta+ArrowUp') await expect .poll(async () => getActiveTerminalViewport(orcaPage), { @@ -859,7 +905,7 @@ test.describe('Terminal Shortcuts', () => { .toMatchObject({ viewportY: 0 }) expect(await getPtyWrites(electronApp)).toEqual([]) - await focusActiveTerminal(orcaPage) + await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.press('Meta+ArrowDown') await expect .poll( From 84d375ae06008402418b5559e9774ba8c6e2d2b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:04:59 -0700 Subject: [PATCH 021/138] perf: avoid rescanning idle serve-sim PTY output (#8166) * perf: avoid scanning idle serve-sim PTY output * test: record serve-sim PTY performance gate * fix(renderer): dedupe connection context import --- config/reliability-gates.jsonc | 34 ++++++++++--- .../emulator/serve-sim-state-watcher.test.ts | 48 +++++++++++++++++++ src/main/emulator/serve-sim-state-watcher.ts | 21 ++++++-- .../src/components/tab-bar/TabBar.tsx | 1 - 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d43e149d449..68eb468c9df 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1222,6 +1222,7 @@ "daemon stream", "main PTY batching", "runtime path provenance", + "runtime serve-sim detection", "renderer ACK", "xterm scheduler", "hidden output" @@ -1241,22 +1242,24 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and idle-path serve-sim detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata and detectors, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", + "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", - "src/main/runtime/orca-runtime-path-candidate-history.test.ts" + "src/main/runtime/orca-runtime-path-candidate-history.test.ts", + "src/main/emulator/serve-sim-state-watcher.test.ts" ], "assertionRefs": [ { @@ -1273,6 +1276,13 @@ "reuses path-candidate history across repeated pathless PTY output", "copies history when new output adds a path candidate" ] + }, + { + "file": "src/main/emulator/serve-sim-state-watcher.test.ts", + "assertions": [ + "does not buffer repeated brace-free PTY output while waiting for metadata", + "detects serve-sim metadata split across PTY chunks and releases the partial object" + ] } ], "evidenceRuns": [ @@ -1293,6 +1303,15 @@ "result": "passed", "durationSeconds": 3.49, "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", + "result": "passed", + "durationSeconds": 0.37, + "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." } ], "runtimeBudget": { @@ -1305,11 +1324,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; the fixed shape passed in 6ms. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, - "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements." + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks." }, "promotionCriteria": [ "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", @@ -1319,6 +1338,7 @@ "knownGaps": [ "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", + "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." ], diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index bf9d7a61fa1..69b57d60b3b 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -78,6 +78,54 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { + const watcher = createIsolatedWatcher() + const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers + const originalSet = buffers.set + let setCalls = 0 + buffers.set = function (key, value): Map { + setCalls += 1 + return originalSet.call(this, key, value) + } + watcher.bindPty('pty-1', 'worktree-1') + + try { + for (let index = 0; index < 4096; index += 1) { + watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n') + } + } finally { + buffers.set = originalSet + } + + expect(setCalls).toBe(0) + expect(buffers.has('pty-1')).toBe(false) + watcher.stop() + }) + + it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => { + const watcher = createIsolatedWatcher() + const events: ServeSimStateDetectedEvent[] = [] + const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers + const payload = JSON.stringify({ + device: TEST_UDID, + streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3100/ws' + }) + const splitIndex = payload.indexOf('streamUrl') + 4 + watcher.bindPty('pty-1', 'worktree-1') + watcher.onDetected((event) => events.push(event)) + + watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex)) + expect(events).toHaveLength(0) + expect(buffers.has('pty-1')).toBe(true) + + watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex)) + expect(events).toHaveLength(1) + expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) + expect(buffers.has('pty-1')).toBe(false) + watcher.stop() + }) + it('suppresses Orca-managed sessions only while they are marked managed', async () => { const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index f250fe7f7b9..1a42b2d0353 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -66,6 +66,11 @@ function helperInstanceKey(info: ServeSimHelperInfo): string { return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}` } +function trailingIncompletePtyJsonObject(data: string): string { + const lastObjectStart = data.lastIndexOf('{') + return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : '' +} + export class ServeSimStateWatcher { private readonly stateDir: string private readonly ptyToWorktree = new Map() @@ -132,9 +137,19 @@ export class ServeSimStateWatcher { return } - const prev = this.ptyBuffers.get(ptyId) ?? '' - const combined = (prev + data).slice(-16_384) - this.ptyBuffers.set(ptyId, combined) + const previous = this.ptyBuffers.get(ptyId) + if (!previous && !data.includes('{')) { + // Why: serve-sim metadata is a JSON object, while ordinary PTY output is + // the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB. + return + } + const combined = `${previous ?? ''}${data}`.slice(-16_384) + const trailingObject = trailingIncompletePtyJsonObject(combined) + if (trailingObject) { + this.ptyBuffers.set(ptyId, trailingObject) + } else { + this.ptyBuffers.delete(ptyId) + } const matches = combined.match(PTY_JSON_RE) if (!matches) { diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 94c09c405de..292952390bf 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -83,7 +83,6 @@ import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' -import { getConnectionIdFromState } from '@/lib/connection-context' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') From 6b7732d511879cc86f1674af47483348197e3e16 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:10:29 -0700 Subject: [PATCH 022/138] perf: skip terminal wait scans for ordinary output (#8169) * perf: skip terminal wait scans for ordinary output * test: record terminal wait performance gate * docs: clarify terminal wait allocation proof --- config/reliability-gates.jsonc | 45 +++++++++++++--- .../orca-runtime-tail-wait-memo.test.ts | 23 ++++++-- src/main/runtime/orca-runtime.ts | 54 ++++++++++++++++--- 3 files changed, 105 insertions(+), 17 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 68eb468c9df..1a2bb248fd6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1223,6 +1223,7 @@ "main PTY batching", "runtime path provenance", "runtime serve-sim detection", + "runtime terminal wait detection", "renderer ACK", "xterm scheduler", "hidden output" @@ -1242,24 +1243,27 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and idle-path serve-sim detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/7002", "https://github.com/stablyai/orca/pull/7054" ], - "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata and detectors, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", + "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", "src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "src/main/emulator/serve-sim-state-watcher.test.ts" + "src/main/emulator/serve-sim-state-watcher.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "assertionRefs": [ { @@ -1283,6 +1287,23 @@ "does not buffer repeated brace-free PTY output while waiting for metadata", "detects serve-sim metadata split across PTY chunks and releases the partial object" ] + }, + { + "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", + "assertions": [ + "does not rebuild or repeatedly scan an ordinary saturated tail", + "memoized stamping matches recompute reference: blocked prompt split across chunks", + "stays equivalent across tail eviction beyond the retained cap" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "returns a blocked wait result for Codex update prompts", + "returns a blocked wait result for Codex workspace trust prompts", + "returns a blocked wait result for generic Codex interactive prompts", + "resolves tui-idle when a stale Codex prompt is followed by the ready header" + ] } ], "evidenceRuns": [ @@ -1312,6 +1333,15 @@ "result": "passed", "durationSeconds": 0.37, "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", + "result": "passed", + "durationSeconds": 21.33, + "summary": "2 test files passed, 609 tests passed on pushed commit 4fb14eac3897." } ], "runtimeBudget": { @@ -1324,11 +1354,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, - "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks." + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates." }, "promotionCriteria": [ "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", @@ -1339,6 +1369,7 @@ "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", + "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." ], diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index 7f3c8a5201f..f61431b7b86 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { appendNormalizedToTailBuffer, buildPreview, @@ -10,8 +10,8 @@ import { // These tests pin the onPtyData wait-detection memoization: caching the // post-append wait state and reusing it as the next chunk's pre-append state // must produce byte-for-byte the same waitBlockedAt stamping as recomputing the -// full-tail scan on both sides of every chunk (the pre-memoization behavior), -// while doing roughly half the full-tail work. +// wait-state scan on both sides of every chunk (the pre-memoization behavior), +// while doing roughly half the state computations. type RedrawCursor = ReturnType['redrawCursor'] @@ -137,6 +137,21 @@ describe('onPtyData tail wait memoization', () => { expect(blocked.signal?.reason).toBe('codex-update-prompt') }) + it('does not rebuild or repeatedly scan an ordinary saturated tail', () => { + const lines = Array.from({ length: 2000 }, () => 'x'.repeat(126)) + const lastIndexOf = vi.spyOn(String.prototype, 'lastIndexOf') + + try { + const state = computeTerminalTailWaitState(lines, '', '') + + expect(state.signal).toBeNull() + expect(state.waitText).toBe('') + expect(lastIndexOf).not.toHaveBeenCalled() + } finally { + lastIndexOf.mockRestore() + } + }) + for (const [name, chunks] of Object.entries(SCENARIOS)) { it(`memoized stamping matches recompute reference: ${name}`, () => { const { memoized, reference } = runBoth(chunks) @@ -194,7 +209,7 @@ describe('onPtyData tail wait memoization', () => { expect(memoSim.waitBlockedAt).not.toBeNull() }) - it('does roughly half the full-tail wait scans of the recompute reference', () => { + it('does roughly half the wait-state computations of the recompute reference', () => { const chunks: string[] = [] for (let i = 0; i < 500; i += 1) { chunks.push(`streaming line ${i}\n`) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ddeb56f9aa5..cd1fe7be87f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -24522,17 +24522,28 @@ export type TerminalTailWaitState = { fromTail: boolean } -// Why: onPtyData runs per raw PTY chunk (hundreds/sec under load). Building the -// wait text (a full map/trim/filter/join over the up-to-256KB retained tail) -// and lower-casing + scanning it once per chunk is unavoidable, but the old -// code did it twice — once for the pre-append tail and once for the post-append -// tail — every chunk. Caching the post-append state lets the next chunk reuse it -// as its pre-append state, halving the per-chunk full-tail work. +// Why: onPtyData runs per raw PTY chunk (hundreds/sec under load). Ordinary +// tails take one no-join sentinel pass; only candidate-bearing tails +// build, lowercase, and parse the full 256 KiB text. The cached post-append +// state also avoids repeating that work for the next chunk's previous state. export function computeTerminalTailWaitState( lines: string[], partialLine: string, preview: string ): TerminalTailWaitState { + const tailShape = inspectTerminalWaitTail(lines, partialLine) + if (!tailShape.fromTail) { + return { + waitText: preview, + signal: findActionableTerminalWaitBlockedSignal(preview.toLowerCase()), + fromTail: false + } + } + if (!tailShape.mayContainBlockedSignal) { + // Why: tailGainedNewerBlockedReason reads waitText only when signal exists; + // avoid retaining a rebuilt 256 KiB string for the overwhelmingly common case. + return { waitText: '', signal: null, fromTail: true } + } const tailText = buildTailLines(lines, partialLine) .map((line) => line.trim()) .filter(Boolean) @@ -24546,6 +24557,29 @@ export function computeTerminalTailWaitState( } } +function inspectTerminalWaitTail( + lines: string[], + partialLine: string +): { fromTail: boolean; mayContainBlockedSignal: boolean } { + let fromTail = false + let mayContainBlockedSignal = false + for (const line of lines) { + if (!fromTail && line.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + mayContainBlockedSignal = true + } + } + if (!fromTail && partialLine.trim().length > 0) { + fromTail = true + } + if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { + mayContainBlockedSignal = true + } + return { fromTail, mayContainBlockedSignal } +} + // Why: decides whether the appended chunk introduced a newer actionable blocked // prompt, consuming precomputed wait states so the full-tail scans are not // repeated per chunk (replaces the former inline double full-tail scan). @@ -25587,9 +25621,17 @@ function isTerminalWaitWhitespace(value: string, index: number): boolean { return code === 32 || (code >= 9 && code <= 13) } +const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + function findTerminalWaitBlockedSignal( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + // Why: this runs once per PTY chunk over a tail up to 256 KiB. One combined + // negative scan avoids a dozen full-tail searches when no prompt can match. + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { From 20c9e09ccac7aa6c8dac5704bb02fc26c22c8e4d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:18:56 -0700 Subject: [PATCH 023/138] test(renderer): restore terminal-create fixture parity (#8208) --- src/renderer/src/hooks/useIpcEvents.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 28d2aa993dd..38822767e1a 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1683,6 +1683,10 @@ describe('useIpcEvents updater integration', () => { updateTabPtyId, setTabLayout, tabsByWorktree: {} as Record, + folderWorkspaces: [], + projectGroups: [], + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-2', repoId: 'repo-1' }] }, openFiles: [], browserTabsByWorktree: {}, tabBarOrderByWorktree: {}, From c5044042ead0adc91fb1d637d1d62b566b5933f5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:22:18 -0700 Subject: [PATCH 024/138] Ai vault view log live tail (#8205) * Add read-only View Log tabs so AI Vault agent sessions open inside Orca' - Adds a `readOnly` flag on OpenFile that hard-blocks edits, autosave, dirty state, rename, and drafts, and persists/restores it safely across sessions - Wires AI Vault's View Log/Open Log actions to open logs as permanent read-only local tabs instead of shelling out to the OS, gated to local, single-file, non-synthetic session paths - Registers a dedicated `jsonl` Monaco language (JSON-style coloring without whole-document JSON validation) and forces read-only tabs to render as raw source, bypassing markdown/mermaid/csv/notebook viewers * Add live-tail streaming for local AI Vault View Log tabs - Extend the fs:readFile snapshot path with byte-stable file identity so a read-only tab can resume appending exactly where the snapshot left off. - Add a ranged local log tail reader plus IPC (read/start/stop watch) that streams only newly appended bytes, detects truncation/rotation, and cleans up watchers on tab close or renderer destruction. - Add a renderer-side UTF-8/line-boundary decoder and useLocalLogTail hook that appends completed lines into the existing Monaco model, falling back to a full reload on reset/rotation, keeping snapshot behavior unchanged when liveTail is not opted in. - Thread the new `liveTail` flag through OpenFile/PersistedOpenFile, workspace session persistence/restore, and the AI Vault "View Log" open path so live tail survives restarts and stays read-only-safe. * Remove stale planning brief for the live-tail feature The AI-VAULT-VIEW-LOG-LIVE-TAIL.md pick-up brief is no longer needed now that the live-tail streaming work has landed (c7fdef7b1). --- .../ai-vault/local-log-tail-reader.test.ts | 72 +++++ src/main/ai-vault/local-log-tail-reader.ts | 78 ++++++ src/main/ipc/filesystem.test.ts | 29 ++ src/main/ipc/filesystem.ts | 49 +++- src/main/ipc/local-log-tail.test.ts | 127 +++++++++ src/main/ipc/local-log-tail.ts | 109 ++++++++ src/preload/api-types.ts | 19 +- src/preload/index.ts | 32 ++- .../src/components/editor/EditorContent.tsx | 14 +- .../editor/editor-autosave-controller.ts | 7 + .../components/editor/editor-autosave.test.ts | 4 + .../src/components/editor/editor-autosave.ts | 5 + .../editor/editor-header-file-rename.ts | 8 +- .../editor/editor-panel-content-types.ts | 1 + .../editor/editor-panel-render-model.test.ts | 21 ++ .../editor/editor-panel-render-model.ts | 30 ++- .../editor/local-log-tail-decoder.test.ts | 60 +++++ .../editor/local-log-tail-decoder.ts | 62 +++++ .../editor/useEditorPanelContentState.ts | 7 +- .../editor/useLocalLogTail.test.tsx | 187 +++++++++++++ .../src/components/editor/useLocalLogTail.ts | 250 ++++++++++++++++++ .../components/right-sidebar/AiVaultPanel.tsx | 3 +- .../right-sidebar/AiVaultSessionSubagents.tsx | 38 +-- .../AiVaultSessionVirtualList.tsx | 11 +- .../ai-vault-session-log-open.test.ts | 168 ++++++++++++ .../ai-vault-session-log-open.ts | 151 +++++++++++ .../ai-vault-session-path-actions.test.ts | 46 +++- .../ai-vault-session-path-actions.ts | 28 ++ .../src/components/tab-bar/EditorFileTab.tsx | 6 +- src/renderer/src/lib/language-detect.test.ts | 11 + src/renderer/src/lib/language-detect.ts | 4 + .../monaco-languages/register-jsonl.test.ts | 54 ++++ .../lib/monaco-languages/register-jsonl.ts | 71 +++++ src/renderer/src/lib/monaco-setup.ts | 2 + .../workspace-session-editor-drafts.test.ts | 59 +++++ src/renderer/src/lib/workspace-session.ts | 9 +- .../src/runtime/runtime-file-client.ts | 9 +- src/renderer/src/store/slices/editor.test.ts | 123 +++++++++ src/renderer/src/store/slices/editor.ts | 46 +++- src/renderer/src/web/web-preload-api.ts | 8 + src/shared/local-log-tail-types.ts | 26 ++ src/shared/types.ts | 5 + src/shared/workspace-session-schema.ts | 4 +- 43 files changed, 1998 insertions(+), 55 deletions(-) create mode 100644 src/main/ai-vault/local-log-tail-reader.test.ts create mode 100644 src/main/ai-vault/local-log-tail-reader.ts create mode 100644 src/main/ipc/local-log-tail.test.ts create mode 100644 src/main/ipc/local-log-tail.ts create mode 100644 src/renderer/src/components/editor/local-log-tail-decoder.test.ts create mode 100644 src/renderer/src/components/editor/local-log-tail-decoder.ts create mode 100644 src/renderer/src/components/editor/useLocalLogTail.test.tsx create mode 100644 src/renderer/src/components/editor/useLocalLogTail.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts create mode 100644 src/renderer/src/lib/monaco-languages/register-jsonl.test.ts create mode 100644 src/renderer/src/lib/monaco-languages/register-jsonl.ts create mode 100644 src/shared/local-log-tail-types.ts diff --git a/src/main/ai-vault/local-log-tail-reader.test.ts b/src/main/ai-vault/local-log-tail-reader.test.ts new file mode 100644 index 00000000000..cc0c1889e40 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { LOCAL_LOG_TAIL_CHUNK_BYTES } from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from './local-log-tail-reader' + +const tempPaths: string[] = [] + +async function makeLog(content: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-log-tail-')) + tempPaths.push(directory) + const filePath = join(directory, 'session.jsonl') + await writeFile(filePath, content) + return filePath +} + +afterEach(async () => { + await Promise.all(tempPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('readLocalLogTailRange', () => { + it('reads only the requested appended range', async () => { + const filePath = await makeLog('first\nsecond\n') + const result = await readLocalLogTailRange(filePath, Buffer.byteLength('first\n')) + + expect(Buffer.from(result.contentBase64, 'base64').toString('utf8')).toBe('second\n') + expect(result.nextByteOffset).toBe(Buffer.byteLength('first\nsecond\n')) + expect(result.hasMore).toBe(false) + expect(result.reset).toBe(false) + }) + + it('caps each response and reports that more bytes remain', async () => { + const filePath = await makeLog('x'.repeat(LOCAL_LOG_TAIL_CHUNK_BYTES + 17)) + const result = await readLocalLogTailRange(filePath, 0) + + expect(Buffer.from(result.contentBase64, 'base64')).toHaveLength(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.nextByteOffset).toBe(LOCAL_LOG_TAIL_CHUNK_BYTES) + expect(result.hasMore).toBe(true) + }) + + it('requests a reset after truncation', async () => { + const filePath = await makeLog('first\nsecond\n') + const initial = await readLocalLogTailRange(filePath, 0) + await truncate(filePath, 2) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.nextByteOffset).toBe(0) + expect(result.fileSize).toBe(2) + }) + + it('requests a reset when the file is atomically replaced', async () => { + const filePath = await makeLog('old\n') + const initial = await readLocalLogTailRange(filePath, 0) + const replacement = `${filePath}.next` + await writeFile(replacement, 'new and longer\n') + await rename(replacement, filePath) + + const result = await readLocalLogTailRange( + filePath, + initial.nextByteOffset, + initial.fileIdentity + ) + expect(result.reset).toBe(true) + expect(result.fileIdentity).not.toBe(initial.fileIdentity) + }) +}) diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts new file mode 100644 index 00000000000..3045981e5c5 --- /dev/null +++ b/src/main/ai-vault/local-log-tail-reader.ts @@ -0,0 +1,78 @@ +import { open } from 'node:fs/promises' +import { + LOCAL_LOG_TAIL_CHUNK_BYTES, + type LocalLogTailReadResult +} from '../../shared/local-log-tail-types' + +export function localLogFileIdentity(stats: { + dev: number + ino: number + birthtimeMs: number +}): string { + // Why: inode catches atomic replacement on POSIX while birthtime covers + // platforms/filesystems that report an unusable inode value. + return `${stats.dev}:${stats.ino}:${stats.birthtimeMs}` +} + +export async function readLocalLogTailRange( + filePath: string, + fromByteOffset: number, + expectedIdentity?: string +): Promise { + if (!Number.isSafeInteger(fromByteOffset) || fromByteOffset < 0) { + throw new Error('Invalid local log tail byte offset') + } + + const handle = await open(filePath, 'r') + try { + const initialStats = await handle.stat() + if (!initialStats.isFile()) { + throw new Error('Local log tail target is not a file') + } + const fileIdentity = localLogFileIdentity(initialStats) + if ( + fromByteOffset > initialStats.size || + (expectedIdentity !== undefined && expectedIdentity !== fileIdentity) + ) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: initialStats.size, + fileIdentity, + hasMore: initialStats.size > 0, + reset: true + } + } + + const bytesToRead = Math.min(LOCAL_LOG_TAIL_CHUNK_BYTES, initialStats.size - fromByteOffset) + const buffer = Buffer.allocUnsafe(bytesToRead) + const { bytesRead } = + bytesToRead > 0 ? await handle.read(buffer, 0, bytesToRead, fromByteOffset) : { bytesRead: 0 } + const nextByteOffset = fromByteOffset + bytesRead + const finalStats = await handle.stat() + + // Why: truncation can race the ranged read on the same open handle. Do not + // let an offset beyond the new EOF become the baseline for later appends. + if (nextByteOffset > finalStats.size) { + return { + contentBase64: '', + nextByteOffset: 0, + fileSize: finalStats.size, + fileIdentity, + hasMore: finalStats.size > 0, + reset: true + } + } + + return { + contentBase64: buffer.subarray(0, bytesRead).toString('base64'), + nextByteOffset, + fileSize: finalStats.size, + fileIdentity, + hasMore: nextByteOffset < finalStats.size, + reset: false + } + } finally { + await handle.close() + } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 072f8239dcb..2e9d1843988 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -907,6 +907,35 @@ describe('registerFilesystemHandlers', () => { }) }) + it('returns stable byte metadata only for opted-in local log snapshots', async () => { + const content = Buffer.from('first\npartial') + const close = vi.fn() + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + dev: 1, + ino: 2, + birthtimeMs: 3 + }), + readFile: vi.fn().mockResolvedValue(content), + close + }) + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('fs:readFile')!(null, { + filePath: path.resolve('/workspace/repo/session.jsonl'), + includeLocalLogMetadata: true + }) + ).resolves.toEqual({ + content: 'first\npartial', + isBinary: false, + fileIdentity: '1:2:3' + }) + expect(close).toHaveBeenCalledTimes(1) + expect(readFileMock).not.toHaveBeenCalled() + }) + it('rejects text files beyond the editor read budget', async () => { statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 100245dca8b..3918f8d3ea4 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -120,6 +120,8 @@ import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-er import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { registerLocalLogTailHandlers } from './local-log-tail' +import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -148,6 +150,38 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } function validateRequiredString(value: unknown, label: string): string { @@ -534,13 +568,22 @@ export function registerFilesystemHandlers( 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => { + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE @@ -2216,4 +2259,6 @@ export function registerFilesystemHandlers( return getRemoteCommitUrl(worktreePath, sha) } ) + + registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts new file mode 100644 index 00000000000..beac464f4a4 --- /dev/null +++ b/src/main/ipc/local-log-tail.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, watchMock, resolveAuthorizedPathMock, readRangeMock } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + watchMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + readRangeMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('node:fs', () => ({ watch: watchMock })) + +vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) + +vi.mock('../ai-vault/local-log-tail-reader', () => ({ + readLocalLogTailRange: readRangeMock +})) + +import { + closeAllLocalLogTailWatchers, + getActiveLocalLogTailWatcherCount, + registerLocalLogTailHandlers +} from './local-log-tail' + +type FakeWatcher = { + close: ReturnType + on: ReturnType + emitError: () => void +} + +function makeWatcher(): FakeWatcher { + let errorListener: (() => void) | undefined + return { + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === 'error') { + errorListener = listener + } + }), + emitError: () => errorListener?.() + } +} + +function makeSender(id: number) { + let destroyedListener: (() => void) | undefined + return { + id, + send: vi.fn(), + isDestroyed: vi.fn(() => false), + once: vi.fn((event: string, listener: () => void) => { + if (event === 'destroyed') { + destroyedListener = listener + } + }), + destroy: () => destroyedListener?.() + } +} + +beforeEach(() => { + handlers.clear() + watchMock.mockReset() + resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) + readRangeMock.mockReset() + registerLocalLogTailHandlers({} as never) +}) + +afterEach(() => { + closeAllLocalLogTailWatchers() +}) + +describe('local log tail IPC', () => { + it('watches only the authorized file and closes on explicit tab cancellation', async () => { + const watcher = makeWatcher() + let emitChange: ((eventType: 'change' | 'rename') => void) | undefined + watchMock.mockImplementation((_path: string, listener: typeof emitChange) => { + emitChange = listener + return watcher + }) + const sender = makeSender(7) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/session.jsonl', subscriptionId: 'tail-1' } + ) + emitChange?.('change') + + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) + expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { + subscriptionId: 'tail-1', + eventType: 'change' + }) + expect(getActiveLocalLogTailWatcherCount()).toBe(1) + + handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId: 'tail-1' }) + expect(watcher.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) + + it('closes every watcher owned by a destroyed renderer', async () => { + const first = makeWatcher() + const second = makeWatcher() + watchMock.mockReturnValueOnce(first).mockReturnValueOnce(second) + const sender = makeSender(9) + + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/a.jsonl', subscriptionId: 'tail-a' } + ) + await handlers.get('fs:startLocalLogTail')?.( + { sender }, + { filePath: '/logs/b.jsonl', subscriptionId: 'tail-b' } + ) + sender.destroy() + + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).toHaveBeenCalledTimes(1) + expect(getActiveLocalLogTailWatcherCount()).toBe(0) + }) +}) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts new file mode 100644 index 00000000000..430882b4e07 --- /dev/null +++ b/src/main/ipc/local-log-tail.ts @@ -0,0 +1,109 @@ +import { ipcMain, type WebContents } from 'electron' +import { watch, type FSWatcher } from 'node:fs' +import type { Store } from '../persistence' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' +import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' +import { resolveAuthorizedPath } from './filesystem-auth' + +type TailWatch = { + senderId: number + watcher: FSWatcher +} + +const tailWatches = new Map() +const senderCleanupRegistered = new Set() + +function watchKey(senderId: number, subscriptionId: string): string { + return `${senderId}:${subscriptionId}` +} + +function closeWatch(key: string): void { + const subscription = tailWatches.get(key) + if (!subscription) { + return + } + tailWatches.delete(key) + subscription.watcher.close() +} + +function closeSenderWatches(senderId: number): void { + senderCleanupRegistered.delete(senderId) + for (const [key, subscription] of tailWatches) { + if (subscription.senderId === senderId) { + closeWatch(key) + } + } +} + +function validateSubscriptionId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 200) { + throw new Error('Invalid local log tail subscription id') + } + return value +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + sender.once('destroyed', () => closeSenderWatches(sender.id)) +} + +export function registerLocalLogTailHandlers(store: Store): void { + ipcMain.handle( + 'fs:readLocalLogTail', + async (_event, args: LocalLogTailReadArgs): Promise => { + const filePath = await resolveAuthorizedPath(args.filePath, store) + return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) + } + ) + + ipcMain.handle( + 'fs:startLocalLogTail', + async (event, args: LocalLogTailWatchArgs): Promise => { + const subscriptionId = validateSubscriptionId(args.subscriptionId) + const filePath = await resolveAuthorizedPath(args.filePath, store) + const key = watchKey(event.sender.id, subscriptionId) + closeWatch(key) + + const sendChange = (eventType: 'change' | 'rename'): void => { + if (!tailWatches.has(key) || event.sender.isDestroyed()) { + return + } + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + event.sender.send('fs:localLogTailChanged', payload) + } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + watcher.on('error', () => { + // Why: an error commonly accompanies rotation. Signal one final drain so + // the renderer can detect identity change, then release the dead handle. + sendChange('rename') + closeWatch(key) + }) + tailWatches.set(key, { senderId: event.sender.id, watcher }) + registerSenderCleanup(event.sender) + } + ) + + ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { + closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))) + }) +} + +export function closeAllLocalLogTailWatchers(): void { + for (const key of Array.from(tailWatches.keys())) { + closeWatch(key) + } + senderCleanupRegistered.clear() +} + +/** Test-only: verifies tab/window teardown does not retain native watchers. */ +export function getActiveLocalLogTailWatcherCount(): number { + return tailWatches.size +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1001d14f966..7731355972c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,12 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { @@ -2351,7 +2357,18 @@ export type PreloadApi = { readFile: (args: { filePath: string connectionId?: string - }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> + includeLocalLogMetadata?: boolean + }) => Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> + readLocalLogTail: (args: LocalLogTailReadArgs) => Promise + startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise + stopLocalLogTail: (args: { subscriptionId: string }) => Promise + onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void downloadFile: (args: { filePath: string connectionId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index a4d60262a54..4ad832fa89a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -195,6 +195,12 @@ import { type NativeFileDropPayload, type NativeFileDropPathEntry } from '../shared/native-file-drop' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../shared/local-log-tail-types' import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' @@ -2720,8 +2726,30 @@ const api = { readFile: (args: { filePath: string connectionId?: string - }): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => - ipcRenderer.invoke('fs:readFile', args), + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, downloadFile: (args: { filePath: string connectionId: string diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 76996c0ac16..abab01bf142 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -46,6 +46,12 @@ const MermaidViewer = lazy(() => import('./MermaidViewer')) const CsvViewer = lazy(() => import('./CsvViewer')) const IpynbViewer = lazy(() => import('./IpynbViewer')) +// Why: stable no-op callbacks for read-only tabs so Monaco never routes a +// content-change or save through the writable pipeline (and so we don't create +// a new function identity each render). +const noopEditorContentChange = (_content: string): void => {} +const noopEditorSave = (_content: string): void => {} + export function getMarkdownSourceLineOffset(frontMatterRaw: string): number { let offset = 0 @@ -317,8 +323,12 @@ export function EditorContent({ relativePath={activeFile.relativePath} content={editBuffers[activeFile.id] ?? fc.content} language={monacoLanguage} - onContentChange={handleContentChange} - onSave={isMarkdown ? md.mdSave : handleSave} + // Why: read-only tabs (AI Vault View Log) block edits in Monaco and no-op + // the change/save callbacks so no draft, dirty state, or write can occur — + // mirrors the conflict-review read-only rendering pattern. + readOnly={activeFile.readOnly === true} + onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange} + onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave} worktreeId={activeFile.worktreeId} markdownAnnotationsEnabled={markdownAnnotationsEnabled && isMarkdown} conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'} diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 7c6f043bd8a..8c8e7ec16a0 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -93,6 +93,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + // Why: hard no-op for read-only tabs (AI Vault View Log) even if a stray + // user/autosave save reached the queue — the integrity invariant is that + // View Log never writes the agent-owned artifact through editor paths. + if (liveFile.readOnly === true) { + return + } + // Why: explicit user saves proceed even while suspended (the banner // warned) and clear both suspension flags below. if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { diff --git a/src/renderer/src/components/editor/editor-autosave.test.ts b/src/renderer/src/components/editor/editor-autosave.test.ts index 20d32f8f6a7..77f6065153d 100644 --- a/src/renderer/src/components/editor/editor-autosave.test.ts +++ b/src/renderer/src/components/editor/editor-autosave.test.ts @@ -91,6 +91,10 @@ describe('canAutoSaveOpenFile', () => { ) ).toBe(false) }) + + it('rejects read-only edit tabs (AI Vault View Log)', () => { + expect(canAutoSaveOpenFile(makeOpenFile({ readOnly: true }))).toBe(false) + }) }) describe('normalizeAutoSaveDelayMs', () => { diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index c36f91e8a9b..d2a5034f5cb 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -70,6 +70,11 @@ export function isWorkingTreeCombinedDiffTab(file: OpenFile): boolean { } export function canAutoSaveOpenFile(file: OpenFile): boolean { + // Why: read-only tabs (AI Vault View Log) must never autosave — writing an + // agent-owned transcript can corrupt the provider's resume history. + if (file.readOnly === true) { + return false + } // Why: single-file editors and one-file unstaged diffs have an unambiguous // write target. Combined diff and conflict-review tabs can represent multiple // paths, so autosave must stay out of those surfaces until they have their diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 2d601b99d6d..16f5e557faa 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -23,8 +23,14 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil const renameCancelledRef = useRef(false) const renameFocusFrameRef = useRef(null) const currentFileName = basename(activeFile.filePath) + // Why: read-only tabs (AI Vault View Log) are never renameable — rename would + // rewrite the agent-owned artifact's backing path. const canRename = - activeFile.mode === 'edit' && !activeFile.diffSource && !activeFile.conflict && !isRenaming + activeFile.mode === 'edit' && + !activeFile.diffSource && + !activeFile.conflict && + !activeFile.readOnly && + !isRenaming const openRenameInput = (): void => { if (!canRename) { diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 636b910b77d..c44df176150 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -23,6 +23,7 @@ export type FileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string loadError?: string } diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index adbb6eafb9d..fc4efff0a5c 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -109,6 +109,27 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => { }) }) +describe('getEditorPanelRenderModel read-only raw rendering (AI Vault View Log)', () => { + it('renders a read-only markdown log as raw source with no markdown viewer or chrome', () => { + const model = renderModel({ activeFile: markdownFile({ readOnly: true }) }) + + // Raw text renderer, not the rich/preview markdown surface. + expect(model.isMarkdown).toBe(false) + expect(model.hasViewModeToggle).toBe(false) + expect(model.canShowMarkdownPreview).toBe(false) + expect(model.canExportMarkdownToPdf).toBe(false) + expect(model.canShowMarkdownTableOfContents).toBe(false) + // Real language is preserved so Monaco still tokenizes the read-only source. + expect(model.resolvedLanguage).toBe('markdown') + }) + + it('keeps markdown chrome for ordinary writable markdown tabs', () => { + const model = renderModel({ activeFile: markdownFile() }) + expect(model.isMarkdown).toBe(true) + expect(model.hasViewModeToggle).toBe(true) + }) +}) + describe('getEditorPanelRenderModel markdown export affordance', () => { it('enables export for rendered markdown edit tabs', () => { expect(renderModel({}).canExportMarkdownToPdf).toBe(true) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 901c694fad7..4f46a0f1717 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -54,6 +54,14 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' ? detectLanguage(activeFile.relativePath) : detectLanguage(activeFile.filePath) + // Why: an AI Vault View Log tab must show the exact raw bytes read-only. A + // rich/preview/mermaid/csv/notebook renderer would depart from raw text (and + // can look editable), so neutralize specialized viewers + view-mode chrome + // for read-only tabs. `resolvedLanguage` still drives read-only Monaco + // tokenization (e.g. jsonl stays colorized); only viewer selection is forced + // to a plain source rendering here. + const rawReadOnly = activeFile.mode === 'edit' && activeFile.readOnly === true + const viewerLanguage = rawReadOnly ? 'plaintext' : resolvedLanguage const worktreeEntries = gitStatusEntries ?? [] const branchEntries = gitBranchEntries ?? [] const matchingWorktreeEntry = @@ -77,13 +85,13 @@ export function getEditorPanelRenderModel({ matchingBranchEntry ) const markdownViewModes = getMarkdownViewModes({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) const hasViewModeToggle = markdownViewModes.length > 0 const defaultMarkdownViewMode = getDefaultMarkdownViewMode({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) @@ -95,7 +103,7 @@ export function getEditorPanelRenderModel({ ? storedMarkdownViewMode : defaultMarkdownViewMode const editorToggleModes = getEditorToggleModes({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) @@ -115,7 +123,7 @@ export function getEditorPanelRenderModel({ ? (editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null) : null const shouldShowMarkdownExportAction = - resolvedLanguage === 'markdown' && + viewerLanguage === 'markdown' && (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') const inlineMarkdownRenderMode = activeFile.mode === 'edit' && inlineMarkdownContent !== null @@ -147,15 +155,15 @@ export function getEditorPanelRenderModel({ worktreeEntries, resolvedLanguage, openFileState, - isMarkdown: resolvedLanguage === 'markdown', - isMermaid: resolvedLanguage === 'mermaid', - isCsv: resolvedLanguage === 'csv' || resolvedLanguage === 'tsv', - isNotebook: resolvedLanguage === 'notebook', + isMarkdown: viewerLanguage === 'markdown', + isMermaid: viewerLanguage === 'mermaid', + isCsv: viewerLanguage === 'csv' || viewerLanguage === 'tsv', + isNotebook: viewerLanguage === 'notebook', // Why: the preview renders the on-disk file, so diff surfaces only get it // when the modified side still exists on disk (canOpen excludes deleted // files and commit diffs whose content may not match the working tree). canOpenPreviewToSide: - canPreviewLanguage(resolvedLanguage) && + canPreviewLanguage(viewerLanguage) && (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, hasViewModeToggle, @@ -166,10 +174,10 @@ export function getEditorPanelRenderModel({ shouldShowMarkdownExportAction, canExportMarkdownToPdf, canShowMarkdownTableOfContents: - resolvedLanguage === 'markdown' && + viewerLanguage === 'markdown' && (hasViewModeToggle || activeFile.mode === 'markdown-preview'), canShowMarkdownPreview: canOpenMarkdownPreview({ - language: resolvedLanguage, + language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource }) diff --git a/src/renderer/src/components/editor/local-log-tail-decoder.test.ts b/src/renderer/src/components/editor/local-log-tail-decoder.test.ts new file mode 100644 index 00000000000..33d9b3e5eb3 --- /dev/null +++ b/src/renderer/src/components/editor/local-log-tail-decoder.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types' +import { LocalLogTailDecoder } from './local-log-tail-decoder' + +const IDENTITY = '1:2:3' + +function chunk( + content: Uint8Array, + nextByteOffset: number, + overrides: Partial = {} +): LocalLogTailReadResult { + return { + contentBase64: Buffer.from(content).toString('base64'), + nextByteOffset, + fileSize: nextByteOffset, + fileIdentity: IDENTITY, + hasMore: false, + reset: false, + ...overrides + } +} + +describe('LocalLogTailDecoder', () => { + it('rewinds a snapshot to its last complete line', () => { + const decoder = new LocalLogTailDecoder('one\ntwo', IDENTITY) + + expect(decoder.initialVisibleContent).toBe('one\n') + expect(decoder.nextByteOffset).toBe(Buffer.byteLength('one\n')) + }) + + it('carries an incomplete UTF-8 code point and record across reads', () => { + const decoder = new LocalLogTailDecoder('', IDENTITY) + const bytes = Buffer.from('{"text":"雪"}\n', 'utf8') + const split = bytes.indexOf(Buffer.from('雪')) + 1 + + const first = decoder.apply(chunk(bytes.subarray(0, split), split, { hasMore: true })) + const second = decoder.apply(chunk(bytes.subarray(split), bytes.length)) + + expect(first).toEqual({ kind: 'append', content: '', hasMore: true }) + expect(second).toEqual({ kind: 'append', content: '{"text":"雪"}\n', hasMore: false }) + }) + + it('holds a partial final line until a later append completes it', () => { + const decoder = new LocalLogTailDecoder('', IDENTITY) + const firstBytes = Buffer.from('{"partial":') + const secondBytes = Buffer.from('true}\n') + + expect(decoder.apply(chunk(firstBytes, firstBytes.length))).toMatchObject({ content: '' }) + expect(decoder.apply(chunk(secondBytes, firstBytes.length + secondBytes.length))).toMatchObject( + { content: '{"partial":true}\n' } + ) + }) + + it('does not apply bytes when the reader detects truncate or rotation', () => { + const decoder = new LocalLogTailDecoder('old\n', IDENTITY) + const result = decoder.apply(chunk(new Uint8Array(), 0, { reset: true })) + + expect(result).toEqual({ kind: 'reset' }) + }) +}) diff --git a/src/renderer/src/components/editor/local-log-tail-decoder.ts b/src/renderer/src/components/editor/local-log-tail-decoder.ts new file mode 100644 index 00000000000..99c06a080e1 --- /dev/null +++ b/src/renderer/src/components/editor/local-log-tail-decoder.ts @@ -0,0 +1,62 @@ +import type { LocalLogTailReadResult } from '../../../../shared/local-log-tail-types' + +export const LOCAL_LOG_TAIL_MAX_BYTES = 50 * 1024 * 1024 + +export type LocalLogTailDecodeResult = + | { kind: 'append'; content: string; hasMore: boolean } + | { kind: 'reset' } + | { kind: 'limit' } + +function decodeBase64(contentBase64: string): Uint8Array { + const binary = atob(contentBase64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +export class LocalLogTailDecoder { + readonly initialVisibleContent: string + private byteOffset: number + private fileIdentity: string + private readonly decoder = new TextDecoder('utf-8') + private lineCarry = '' + + constructor(snapshotContent: string, fileIdentity: string) { + const lastCompleteLineEnd = snapshotContent.lastIndexOf('\n') + 1 + this.initialVisibleContent = snapshotContent.slice(0, lastCompleteLineEnd) + // Why: rewind to the last complete line, then re-read the trailing record as + // bytes. This preserves a UTF-8 code point split at the snapshot EOF. + this.byteOffset = new TextEncoder().encode(this.initialVisibleContent).byteLength + this.fileIdentity = fileIdentity + } + + get nextByteOffset(): number { + return this.byteOffset + } + + get expectedIdentity(): string { + return this.fileIdentity + } + + apply(result: LocalLogTailReadResult): LocalLogTailDecodeResult { + if (result.reset) { + return { kind: 'reset' } + } + if (result.nextByteOffset > LOCAL_LOG_TAIL_MAX_BYTES) { + return { kind: 'limit' } + } + + this.byteOffset = result.nextByteOffset + this.fileIdentity = result.fileIdentity + this.lineCarry += this.decoder.decode(decodeBase64(result.contentBase64), { stream: true }) + const lastCompleteLineEnd = this.lineCarry.lastIndexOf('\n') + 1 + if (lastCompleteLineEnd === 0) { + return { kind: 'append', content: '', hasMore: result.hasMore } + } + const content = this.lineCarry.slice(0, lastCompleteLineEnd) + this.lineCarry = this.lineCarry.slice(lastCompleteLineEnd) + return { kind: 'append', content, hasMore: result.hasMore } + } +} diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 6e60afbf833..95a85c31c48 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -30,6 +30,7 @@ import { usePruneClosedEditorContent } from './useEditorPanelExternalContentEvents' import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' +import { useLocalLogTail } from './useLocalLogTail' const inFlightFileReads = new Map>() const inFlightDiffReads = new Map>() @@ -172,7 +173,9 @@ export function useEditorPanelContentState({ filePath, relativePath: restoredOpenFile?.relativePath ?? relativePath, worktreeId, - connectionId + connectionId, + includeLocalLogMetadata: + restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true }) as Promise inFlightFileReads.set(key, pending) queueMicrotask(() => { @@ -354,6 +357,8 @@ export function useEditorPanelContentState({ [loadDiffContent, loadFileContent] ) + useLocalLogTail({ openFiles, fileContents, setFileContents, reloadContent }) + useEffect(() => { if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) { const snapshotEntries = activeFile.conflictReview?.entries ?? [] diff --git a/src/renderer/src/components/editor/useLocalLogTail.test.tsx b/src/renderer/src/components/editor/useLocalLogTail.test.tsx new file mode 100644 index 00000000000..270eae768e7 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalLogTail.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' +import { useLocalLogTail } from './useLocalLogTail' + +const FILE_PATH = '/home/user/.codex/sessions/log.jsonl' +const FILE_IDENTITY = '1:2:3' + +const openFile = { + id: FILE_PATH, + filePath: FILE_PATH, + relativePath: FILE_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + isDirty: false, + runtimeEnvironmentId: null, + mode: 'edit', + readOnly: true, + liveTail: true +} satisfies OpenFile + +function encodedResult(content: string, fromByteOffset: number) { + const bytes = Buffer.from(content) + return { + contentBase64: bytes.toString('base64'), + nextByteOffset: fromByteOffset + bytes.byteLength, + fileSize: fromByteOffset + bytes.byteLength, + fileIdentity: FILE_IDENTITY, + hasMore: false, + reset: false + } +} + +let changedListener: ((payload: LocalLogTailChangedPayload) => void) | undefined +let startMock: ReturnType +let stopMock: ReturnType +let readMock: ReturnType +let reloadMock: Mock<(file: OpenFile) => void> +let warnMock: ReturnType + +beforeEach(() => { + changedListener = undefined + startMock = vi.fn().mockResolvedValue(undefined) + stopMock = vi.fn().mockResolvedValue(undefined) + readMock = vi.fn() + reloadMock = vi.fn<(file: OpenFile) => void>() + warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + fs: { + startLocalLogTail: startMock, + stopLocalLogTail: stopMock, + readLocalLogTail: readMock, + onLocalLogTailChanged: vi.fn((listener: (payload: LocalLogTailChangedPayload) => void) => { + changedListener = listener + return vi.fn() + }) + } + } + }) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'api') + warnMock.mockRestore() + vi.clearAllMocks() +}) + +function useHarness(files: OpenFile[], initialContent: FileContent) { + const [contents, setContents] = useState>({ + [FILE_PATH]: initialContent + }) + useLocalLogTail({ + openFiles: files, + fileContents: contents, + setFileContents: setContents, + reloadContent: reloadMock + }) + return contents +} + +describe('useLocalLogTail', () => { + it('re-reads a partial snapshot line and appends it only after completion', async () => { + const snapshot = 'complete\n{"partial":' + const offset = Buffer.byteLength('complete\n') + readMock.mockResolvedValue(encodedResult('{"partial":true}\n', offset)) + + const { result } = renderHook(() => + useHarness([openFile], { + content: snapshot, + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => { + expect(result.current[FILE_PATH]?.content).toBe('complete\n{"partial":true}\n') + }) + expect(readMock).toHaveBeenCalledWith({ + filePath: FILE_PATH, + fromByteOffset: offset, + expectedIdentity: FILE_IDENTITY + }) + }) + + it('waits for watcher startup before cancelling a tab closed in flight', async () => { + let resolveStart: (() => void) | undefined + startMock.mockImplementation( + () => + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const snapshot: FileContent = { + content: 'complete\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + } + const { rerender } = renderHook(({ files }) => useHarness(files, snapshot), { + initialProps: { files: [openFile] } + }) + + rerender({ files: [] }) + await act(async () => resolveStart?.()) + + await waitFor(() => expect(stopMock).toHaveBeenCalledTimes(1)) + expect(readMock).not.toHaveBeenCalled() + }) + + it('falls back to a full reload when a change reveals truncation', async () => { + readMock.mockResolvedValue({ + ...encodedResult('', 0), + nextByteOffset: 0, + reset: true + }) + renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => expect(reloadMock).toHaveBeenCalledWith(openFile)) + await waitFor(() => expect(stopMock).toHaveBeenCalledTimes(1)) + }) + + it('commits complete lines read before a later transient chunk failure', async () => { + const first = encodedResult('new\n', 4) + readMock + .mockResolvedValueOnce({ ...first, fileSize: first.fileSize + 1, hasMore: true }) + .mockRejectedValueOnce(new Error('temporary read failure')) + const { result } = renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + + await waitFor(() => expect(result.current[FILE_PATH]?.content).toBe('old\nnew\n')) + expect(reloadMock).not.toHaveBeenCalled() + }) + + it('treats a rename event as rotation and reloads without another ranged append', async () => { + readMock.mockResolvedValue(encodedResult('', 4)) + renderHook(() => + useHarness([openFile], { + content: 'old\n', + isBinary: false, + fileIdentity: FILE_IDENTITY + }) + ) + await waitFor(() => expect(readMock).toHaveBeenCalledTimes(1)) + const subscriptionId = startMock.mock.calls[0]?.[0].subscriptionId as string + + act(() => changedListener?.({ subscriptionId, eventType: 'rename' })) + + await waitFor(() => expect(reloadMock).toHaveBeenCalledWith(openFile)) + expect(readMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/useLocalLogTail.ts b/src/renderer/src/components/editor/useLocalLogTail.ts new file mode 100644 index 00000000000..9c8e1624a48 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalLogTail.ts @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' +import type { LocalLogTailChangedPayload } from '../../../../shared/local-log-tail-types' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' +import { LocalLogTailDecoder } from './local-log-tail-decoder' + +type TailSession = { + fileId: string + filePath: string + subscriptionId: string + decoder: LocalLogTailDecoder + closed: boolean + reading: boolean + pendingRead: boolean + limited: boolean + startPromise: Promise +} + +type UseLocalLogTailParams = { + openFiles: OpenFile[] + fileContents: Record + setFileContents: Dispatch>> + reloadContent: (file: OpenFile) => void +} + +let nextSubscriptionId = 0 + +function isLocalLiveLog( + file: OpenFile, + content: FileContent | undefined +): content is FileContent & { + fileIdentity: string +} { + return ( + file.mode === 'edit' && + file.readOnly === true && + file.liveTail === true && + (file.runtimeEnvironmentId ?? null) === null && + file.relativePath === file.filePath && + content !== undefined && + content.isBinary === false && + !content.loadError && + typeof content.fileIdentity === 'string' && + content.fileIdentity.length > 0 + ) +} + +function stopTailSession(session: TailSession): void { + if (session.closed) { + return + } + session.closed = true + // Why: start IPC can still be resolving when a tab closes. Stop only after + // start settles so a late-created main-process watcher cannot escape cleanup. + void session.startPromise + .then(() => window.api.fs.stopLocalLogTail({ subscriptionId: session.subscriptionId })) + .catch(() => {}) +} + +export function useLocalLogTail({ + openFiles, + fileContents, + setFileContents, + reloadContent +}: UseLocalLogTailParams): void { + const sessionsRef = useRef(new Map()) + const openFilesRef = useRef(openFiles) + openFilesRef.current = openFiles + const reloadContentRef = useRef(reloadContent) + reloadContentRef.current = reloadContent + const hasLocalLiveTailFile = openFiles.some( + (file) => file.readOnly === true && file.liveTail === true + ) + + const restartFromSnapshot = useCallback((session: TailSession): void => { + const file = openFilesRef.current.find((candidate) => candidate.id === session.fileId) + stopTailSession(session) + sessionsRef.current.delete(session.fileId) + if (file) { + reloadContentRef.current(file) + } + }, []) + + const drain = useCallback( + async (session: TailSession): Promise => { + if (session.closed || session.limited) { + return + } + if (session.reading) { + session.pendingRead = true + return + } + session.reading = true + let appendedContent = '' + let discardAppends = false + try { + do { + session.pendingRead = false + for (;;) { + const result = await window.api.fs.readLocalLogTail({ + filePath: session.filePath, + fromByteOffset: session.decoder.nextByteOffset, + expectedIdentity: session.decoder.expectedIdentity + }) + if (session.closed) { + return + } + const decoded = session.decoder.apply(result) + if (decoded.kind === 'reset') { + discardAppends = true + restartFromSnapshot(session) + return + } + if (decoded.kind === 'limit') { + session.limited = true + void session.startPromise + .then(() => + window.api.fs.stopLocalLogTail({ subscriptionId: session.subscriptionId }) + ) + .catch(() => {}) + console.warn('[ai-vault] stopped live tail at the editor file-size limit') + return + } + appendedContent += decoded.content + if (!decoded.hasMore) { + break + } + } + } while (session.pendingRead && !session.closed) + } catch (error) { + if (!session.closed) { + console.warn('[ai-vault] local log tail read failed', error) + } + } finally { + session.reading = false + if (appendedContent && !discardAppends && !session.closed) { + setFileContents((previous) => { + const current = previous[session.fileId] + if (!current || current.isBinary || current.loadError) { + return previous + } + return { + ...previous, + [session.fileId]: { ...current, content: current.content + appendedContent } + } + }) + } + } + }, + [restartFromSnapshot, setFileContents] + ) + + useEffect(() => { + if (!hasLocalLiveTailFile) { + return + } + const unsubscribe = window.api.fs.onLocalLogTailChanged( + ({ subscriptionId, eventType }: LocalLogTailChangedPayload) => { + const session = Array.from(sessionsRef.current.values()).find( + (candidate) => candidate.subscriptionId === subscriptionId + ) + if (!session) { + return + } + if (eventType === 'rename') { + restartFromSnapshot(session) + return + } + void drain(session) + } + ) + return unsubscribe + }, [drain, hasLocalLiveTailFile, restartFromSnapshot]) + + useEffect(() => { + const liveFileIds = new Set() + for (const file of openFiles) { + const content = fileContents[file.id] + if (!isLocalLiveLog(file, content)) { + continue + } + liveFileIds.add(file.id) + if (sessionsRef.current.has(file.id)) { + continue + } + + const decoder = new LocalLogTailDecoder(content.content, content.fileIdentity) + const subscriptionId = `local-log-tail-${++nextSubscriptionId}` + const session: TailSession = { + fileId: file.id, + filePath: file.filePath, + subscriptionId, + decoder, + closed: false, + reading: false, + pendingRead: false, + limited: false, + startPromise: Promise.resolve() + } + sessionsRef.current.set(file.id, session) + + if (decoder.initialVisibleContent !== content.content) { + setFileContents((previous) => { + const current = previous[file.id] + return current + ? { + ...previous, + [file.id]: { ...current, content: decoder.initialVisibleContent } + } + : previous + }) + } + + session.startPromise = window.api.fs.startLocalLogTail({ + filePath: file.filePath, + subscriptionId + }) + void session.startPromise + .then(() => { + if (session.closed) { + return + } + // Why: this first drain closes the snapshot/watch installation race. + return drain(session) + }) + .catch((error) => { + if (!session.closed) { + console.warn('[ai-vault] local log tail watch failed', error) + } + }) + } + + for (const [fileId, session] of sessionsRef.current) { + if (!liveFileIds.has(fileId)) { + stopTailSession(session) + sessionsRef.current.delete(fileId) + } + } + }, [drain, fileContents, openFiles, setFileContents]) + + useEffect( + () => () => { + for (const session of sessionsRef.current.values()) { + stopTailSession(session) + } + sessionsRef.current.clear() + }, + [] + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 20235973045..c651aabada3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -33,6 +33,7 @@ import { } from './ai-vault-session-resume' import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' +import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' import { AI_VAULT_AGENTS, @@ -391,7 +392,7 @@ export default function AiVaultPanel(): React.JSX.Element { translate('auto.components.right.sidebar.AiVaultPanel.logPath', 'Log path') ) } - onOpenLog={(session) => void window.api.shell.openFilePath(session.filePath)} + onOpenLog={(session) => void openAiVaultSessionLogInOrca(session)} onRevealLog={(session) => void window.api.shell.openPath(session.filePath)} onOpenCwd={(session) => { if (session.cwd) { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx index aa84e4bebe3..6fbde077a9c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' +import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { translate } from '@/i18n/i18n' type SubagentListState = { status: 'loading' } | { status: 'loaded'; sessions: AiVaultSession[] } @@ -143,23 +145,25 @@ function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JS { value0: session.messageCount } )} - + {canOpenAiVaultSessionLogInOrca(session) ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index ce9416ac73a..c9fcadece94 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -21,7 +21,10 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' -import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' +import { + canOpenAiVaultSessionLogInOrca, + canUseLocalAiVaultSessionPathActions +} from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -298,6 +301,10 @@ function AiVaultVirtualRow({ const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' const canOpenLocalSessionPaths = row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) + // Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode) + // identities that have no single file to open, while Reveal/CWD stay on the + // existing local-path gate. + const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session) return (
onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined} onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} onOpenCwd={ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts new file mode 100644 index 00000000000..bde07f87540 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastMock, toastErrorMock, getStateMock } = vi.hoisted(() => ({ + toastMock: vi.fn(), + toastErrorMock: vi.fn(), + getStateMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: Object.assign(toastMock, { error: toastErrorMock }) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/i18n/i18n', () => ({ + // Return the English fallback so assertions can match user-facing copy. + translate: (_key: string, fallback: string) => fallback +})) + +import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' + +const LOG_PATH = '/home/user/.claude/sessions/log.jsonl' + +type FakeState = { + activeWorktreeId: string | null + activeGroupIdByWorktree: Record + openFiles: Record[] + worktreesByRepo: Record + folderWorkspaces: { id: string }[] + openFile: ReturnType +} + +function makeState(overrides: Partial = {}): FakeState { + return { + activeWorktreeId: 'wt-1', + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, + openFiles: [], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] }, + folderWorkspaces: [], + openFile: vi.fn(), + ...overrides + } +} + +let authorizeMock: ReturnType + +beforeEach(() => { + authorizeMock = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: authorizeMock } } }) +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('openAiVaultSessionLogInOrca', () => { + it('authorizes the exact path and opens a permanent read-only local tab', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(authorizeMock).toHaveBeenCalledWith({ targetPath: LOG_PATH }) + expect(state.openFile).toHaveBeenCalledTimes(1) + const [file, options] = state.openFile.mock.calls[0] + expect(file).toEqual({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'jsonl', + mode: 'edit', + readOnly: true, + liveTail: true + }) + expect(options).toEqual({ + preview: false, + forceContentReload: true, + suppressActiveRuntimeFallback: true, + targetGroupId: 'group-1' + }) + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('withholds blank, remote, and synthetic paths without authorizing', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: ' ', executionHostId: 'local' }) + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'ssh:dev-box' }) + await openAiVaultSessionLogInOrca({ + filePath: '/home/user/.opencode/db.sqlite#sess_1', + executionHostId: 'local' + }) + + expect(authorizeMock).not.toHaveBeenCalled() + expect(state.openFile).not.toHaveBeenCalled() + }) + + it('toasts and creates no tab when authorization rejects', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + authorizeMock.mockRejectedValue(new Error('denied')) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't open log — path not authorized.") + }) + + it('toasts and creates no tab when the workspace vanishes after authorization', async () => { + const state = makeState() + const stateAfter = makeState({ worktreesByRepo: {}, folderWorkspaces: [] }) + getStateMock.mockReturnValueOnce(state).mockReturnValue(stateAfter) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).not.toHaveBeenCalled() + expect(stateAfter.openFile).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledWith( + "Couldn't open log — workspace is no longer available." + ) + }) + + it('activates and shows the already-editable toast for an existing writable tab', async () => { + const state = makeState({ + openFiles: [ + { + filePath: LOG_PATH, + mode: 'edit', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + readOnly: undefined + } + ] + }) + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).toHaveBeenCalledTimes(1) + expect(toastMock).toHaveBeenCalledWith('Log is already open for editing.') + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('shares one in-flight open for concurrent clicks of the same path', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + let resolveAuth: (() => void) | undefined + authorizeMock.mockImplementation( + () => + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + + const first = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + const second = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + resolveAuth?.() + await Promise.all([first, second]) + + expect(authorizeMock).toHaveBeenCalledTimes(1) + expect(state.openFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts new file mode 100644 index 00000000000..2f4b6ff495c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -0,0 +1,151 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { detectLanguage } from '@/lib/language-detect' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { translate } from '@/i18n/i18n' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' + +type AiVaultLogSession = Pick + +// Why: rapid double-clicks of View Log during the authorize await must share one +// in-flight open (and toast-once on failure) so a slow FS grant can't spawn +// duplicate tabs or spam error toasts. Keyed by the exact requested path. +const inFlightOpenPaths = new Set() + +function worktreeStillExists(state: AppState, worktreeId: string): boolean { + if (findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)) { + return true + } + // Why: the AI Vault panel can be active inside a folder workspace, whose id is + // keyed differently from repo worktrees. + return (state.folderWorkspaces ?? []).some( + (workspace) => folderWorkspaceKey(workspace.id) === worktreeId + ) +} + +function focusEditorContent(): void { + // Why: land keyboard focus in the editor so Find/selection/copy work + // immediately. Double rAF lets the tab mount and Monaco attach its textarea + // before we focus it (mirrors the modal return-focus surface selector). + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return + } + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const textarea = document.querySelector('.monaco-editor textarea') + textarea?.focus() + }) + }) +} + +/** + * Open a local AI Vault session log inside Orca as a permanent, read-only editor + * tab (or activate an existing tab without reducing its authority). Reuses + * Orca's external-file authorize + `openFile` pipeline; it never grants write + * capability by itself and never redirects the open to a remote host. + */ +export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): Promise { + const filePath = session.filePath?.trim() + // Defensive: UI availability should already withhold blank/remote/synthetic + // paths. Bail silently rather than toast — there is no user-actionable error. + if (!filePath || !canOpenAiVaultSessionLogInOrca(session)) { + return + } + if (inFlightOpenPaths.has(filePath)) { + return + } + inFlightOpenPaths.add(filePath) + try { + const state = useAppStore.getState() + // Snapshot the invoking workspace/group before the authorization await so a + // delayed grant can't retarget the tab into a workspace the user moved to. + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." + ) + ) + return + } + const targetGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? undefined + // Why: an already-open *writable* tab must keep its edit authority — View Log + // only activates it and notifies. Local ownership only (runtimeEnvironmentId + // null) matches the tab this action would create/activate. + const existingWritableTab = state.openFiles.find( + (file) => + file.filePath === filePath && + file.mode === 'edit' && + file.worktreeId === worktreeId && + (file.runtimeEnvironmentId ?? null) === null && + file.readOnly !== true + ) + + try { + // The exact scanned path is the authorization oracle; the user click is the + // trust gesture. Reuses Orca's existing external R/W open grant. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } catch { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.notAuthorized', + "Couldn't open log — path not authorized." + ) + ) + return + } + + const stateAfterAuth = useAppStore.getState() + if (!worktreeStillExists(stateAfterAuth, worktreeId)) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." + ) + ) + return + } + + stateAfterAuth.openFile( + { + filePath, + // Why: keep relativePath === filePath so the external-file contract reads + // the exact authorized path, not a worktree-relative reinterpretation. + relativePath: filePath, + worktreeId, + // Why: the path was discovered on the client-local host — pin local + // ownership so an active runtime can't reinterpret it as a remote path. + runtimeEnvironmentId: null, + language: detectLanguage(filePath), + mode: 'edit', + readOnly: true, + liveTail: true + }, + { + preview: false, + // Why: a repeated View Log refreshes a non-dirty tab; the store skips the + // reload nonce for a dirty writable buffer (no buffer replacement). + forceContentReload: true, + suppressActiveRuntimeFallback: true, + targetGroupId + } + ) + + if (existingWritableTab) { + toast( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.alreadyEditable', + 'Log is already open for editing.' + ) + ) + } + + focusEditorContent() + } finally { + inFlightOpenPaths.delete(filePath) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts index 93fee6c94e9..f546287cf98 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' +import { + canOpenAiVaultSessionLogInOrca, + canUseLocalAiVaultSessionPathActions, + isSyntheticAiVaultSessionPath +} from './ai-vault-session-path-actions' describe('canUseLocalAiVaultSessionPathActions', () => { it('allows OS path actions for local session history', () => { @@ -12,3 +16,43 @@ describe('canUseLocalAiVaultSessionPathActions', () => { expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) }) }) + +describe('isSyntheticAiVaultSessionPath', () => { + it('treats OpenCode `#` identities as synthetic', () => { + expect(isSyntheticAiVaultSessionPath('/home/user/.opencode/db.sqlite#sess_123')).toBe(true) + }) + + it('treats ordinary JSONL/JSON transcript paths as real', () => { + expect(isSyntheticAiVaultSessionPath('/home/user/.claude/sessions/log.jsonl')).toBe(false) + expect(isSyntheticAiVaultSessionPath('C:\\Users\\a\\.codex\\log.json')).toBe(false) + }) +}) + +describe('canOpenAiVaultSessionLogInOrca', () => { + it('allows a local, single-file, non-synthetic path', () => { + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/home/user/.claude/sessions/log.jsonl', + executionHostId: 'local' + }) + ).toBe(true) + }) + + it('withholds blank, remote, and synthetic identities', () => { + expect(canOpenAiVaultSessionLogInOrca({ filePath: ' ', executionHostId: 'local' })).toBe( + false + ) + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/remote/.claude/log.jsonl', + executionHostId: 'ssh:dev-box' + }) + ).toBe(false) + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/home/user/.opencode/db.sqlite#sess_1', + executionHostId: 'local' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index f723bbb1959..5bbbdcf2d1d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -3,6 +3,7 @@ import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -11,3 +12,30 @@ export function canUseLocalAiVaultSessionPathActions( // SSH session history exposes paths that exist on the remote host instead. return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID } + +export function isSyntheticAiVaultSessionPath(filePath: string): boolean { + // Why: newer OpenCode sessions use a synthetic `#` + // scanner identity backed by SQLite — not a real filesystem path. A '#' + // session marker never appears in a genuine local transcript path, so it is + // a reliable v1 signal that there is no single file to open in Orca. + return filePath.includes('#') +} + +/** + * Whether AI Vault `View Log` / `Open Log` can open this session's log inside + * Orca as a read-only tab: a non-blank, local, single-file (non-synthetic) + * path. Remote/runtime and synthetic identities are withheld until AI Vault has + * a provider-owned log-resource contract. + */ +export function canOpenAiVaultSessionLogInOrca( + session: Pick +): boolean { + const filePath = session.filePath?.trim() + if (!filePath) { + return false + } + if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { + return false + } + return !isSyntheticAiVaultSessionPath(filePath) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 2ce15236f40..13efe982db6 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -109,8 +109,10 @@ export default function EditorFileTab({ // user's intent. This flag suppresses the trailing blur-commit. const renameCancelledRef = useRef(false) // Only on-disk edit tabs are renameable. Diff, conflict-review, and - // combined/virtual views don't point at a single concrete file we can safely rename. - const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict + // combined/virtual views don't point at a single concrete file we can safely + // rename. Read-only tabs (AI Vault View Log) also stay unrenameable — rename + // would rewrite the agent-owned artifact's backing path. + const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict && !file.readOnly const openRenameInput = (): void => { if (!canRename) { diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index a223a86453e..e029d4535ec 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -37,4 +37,15 @@ describe('detectLanguage', () => { expect(detectLanguage('rtl/defs.vh')).toBe('verilog') expect(detectLanguage('C:\\rtl\\TOP.SV')).toBe('systemverilog') }) + + it('maps .jsonl files to the dedicated jsonl language id (case-insensitive)', () => { + expect(detectLanguage('/home/user/.claude/sessions/transcript.jsonl')).toBe('jsonl') + expect(detectLanguage('C:\\Users\\alice\\.codex\\LOG.JSONL')).toBe('jsonl') + }) + + it('keeps .json/.jsonc on the built-in json language and unknown on plaintext', () => { + expect(detectLanguage('config/settings.json')).toBe('json') + expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') + expect(detectLanguage('notes/scratch.unknownext')).toBe('plaintext') + }) }) diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index 857750cd308..33895c114d1 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -20,6 +20,10 @@ const EXT_TO_LANGUAGE: Record = { '.cjs': 'javascript', '.json': 'json', '.jsonc': 'json', + // Why: JSONL is one JSON value per line; a dedicated 'jsonl' language gives + // JSON-style color without attaching JSON whole-document diagnostics that + // would flag every record after line one as trailing content. + '.jsonl': 'jsonl', '.ipynb': 'notebook', '.md': 'markdown', '.mdx': 'markdown', diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts new file mode 100644 index 00000000000..8e195a46926 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { + JSONL_LANGUAGE_ID, + jsonlLanguageConfiguration, + jsonlMonarchLanguage, + registerJsonlLanguage +} from './register-jsonl' + +function createMonacoMock(existingLanguageIds: string[] = []) { + return { + languages: { + getLanguages: vi.fn(() => existingLanguageIds.map((id) => ({ id }))), + register: vi.fn(), + setLanguageConfiguration: vi.fn(), + setMonarchTokensProvider: vi.fn() + } + } +} + +describe('registerJsonlLanguage', () => { + it('registers the jsonl language with a Monarch tokenizer and .jsonl extension', () => { + const monaco = createMonacoMock() + + registerJsonlLanguage(monaco as never) + + expect(monaco.languages.register).toHaveBeenCalledWith( + expect.objectContaining({ id: JSONL_LANGUAGE_ID, extensions: ['.jsonl'] }) + ) + expect(monaco.languages.setLanguageConfiguration).toHaveBeenCalledWith( + JSONL_LANGUAGE_ID, + jsonlLanguageConfiguration + ) + expect(monaco.languages.setMonarchTokensProvider).toHaveBeenCalledWith( + JSONL_LANGUAGE_ID, + jsonlMonarchLanguage + ) + }) + + it('does not attach the JSON language service / diagnostics', () => { + // Why: whole-document JSON validation would flag every record after line one + // as trailing content. A Monarch tokens provider is presentation-only. + expect(jsonlMonarchLanguage.tokenizer).toBeDefined() + expect('json' in jsonlMonarchLanguage).toBe(false) + }) + + it('registers once and is idempotent when the language already exists', () => { + const monaco = createMonacoMock([JSONL_LANGUAGE_ID]) + + registerJsonlLanguage(monaco as never) + + expect(monaco.languages.register).not.toHaveBeenCalled() + expect(monaco.languages.setMonarchTokensProvider).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.ts new file mode 100644 index 00000000000..fbe0bb4dbac --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.ts @@ -0,0 +1,71 @@ +import type * as Monaco from 'monaco-editor' + +type MonacoModule = typeof Monaco + +export const JSONL_LANGUAGE_ID = 'jsonl' + +export const jsonlLanguageConfiguration: Monaco.languages.LanguageConfiguration = { + brackets: [ + ['{', '}'], + ['[', ']'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '"', close: '"' } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '"', close: '"' } + ] +} + +// Why: JSONL is one JSON value per line, so we reuse JSON-style Monarch tokens +// for color but must NOT attach Monaco's JSON language service — whole-document +// validation would flag every record after line one as unexpected trailing +// content. This provider is presentation-only: no schema/record validation. +export const jsonlMonarchLanguage: Monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.jsonl', + + tokenizer: { + root: [ + { include: '@whitespace' }, + // Property key vs string value are both quoted; color keys distinctly. + [/"(?:[^"\\]|\\.)*"(?=\s*:)/, 'type.identifier'], + [/"/, 'string', '@string'], + [/[{}[\]]/, '@brackets'], + [/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/, 'number'], + [/\b(?:true|false)\b/, 'keyword'], + [/\bnull\b/, 'keyword'], + [/[:,]/, 'delimiter'] + ], + + whitespace: [[/[ \t\r\n]+/, 'white']], + + string: [ + [/[^"\\]+/, 'string'], + [/\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4})/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'] + ] + } +} + +export function registerJsonlLanguage(monaco: MonacoModule): void { + const languageAlreadyRegistered = monaco.languages + .getLanguages() + .some((language) => language.id === JSONL_LANGUAGE_ID) + if (languageAlreadyRegistered) { + return + } + + monaco.languages.register({ + id: JSONL_LANGUAGE_ID, + extensions: ['.jsonl'], + aliases: ['JSON Lines', 'jsonl', 'ndjson'] + }) + monaco.languages.setLanguageConfiguration(JSONL_LANGUAGE_ID, jsonlLanguageConfiguration) + monaco.languages.setMonarchTokensProvider(JSONL_LANGUAGE_ID, jsonlMonarchLanguage) +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 8a955e99d1c..523ecf8e77b 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -8,6 +8,7 @@ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { registerAstroLanguage } from './monaco-languages/register-astro' +import { registerJsonlLanguage } from './monaco-languages/register-jsonl' import { registerNimLanguage } from './monaco-languages/register-nim' import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' @@ -77,6 +78,7 @@ registerVueLanguage(monaco) registerSvelteLanguage(monaco) registerAstroLanguage(monaco) registerNimLanguage(monaco) +registerJsonlLanguage(monaco) installMonacoDelayerCancellationGuard() installMonacoDiffEditorDisposalGuard(monaco) installMonacoPeekReferencesPreviewOptions() diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 99dd4ec5970..ad12ccec39c 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -123,4 +123,63 @@ describe('workspace session editor drafts', () => { }) ]) }) + + it('persists readOnly:true and never emits a dirty draft for read-only tabs', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/home/user/.claude/log.jsonl', + filePath: '/home/user/.claude/log.jsonl', + relativePath: '/home/user/.claude/log.jsonl', + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + // Why: even if isDirty is somehow set, a read-only tab must not + // persist a draft that a restore could write back to disk. + isDirty: true, + readOnly: true, + liveTail: true + } as never + ], + editorDrafts: { + '/home/user/.claude/log.jsonl': 'stray draft that must not persist' + } + }) + ) + + const persisted = payload.openFilesByWorktree?.['wt-1']?.[0] + expect(persisted).toEqual( + expect.objectContaining({ + filePath: '/home/user/.claude/log.jsonl', + readOnly: true, + liveTail: true + }) + ) + expect(persisted).toEqual( + expect.not.objectContaining({ dirtyDraftContent: expect.any(String) }) + ) + }) + + it('omits readOnly for ordinary writable tabs (writable is the default)', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/tmp/clean.md', + filePath: '/tmp/clean.md', + relativePath: 'clean.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false + } as never + ] + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']?.[0]).toEqual( + expect.not.objectContaining({ readOnly: expect.anything() }) + ) + }) }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 4970840d5fb..21a11eb0eec 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -127,7 +127,10 @@ export function buildEditorSessionData( const editFileIdsByWorktree: Record> = {} for (const f of editFiles) { const arr = byWorktree[f.worktreeId] ?? (byWorktree[f.worktreeId] = []) - const dirtyDraftContent = f.isDirty ? editorDrafts[f.id] : undefined + // Why: read-only tabs never persist a dirty draft even if isDirty is + // somehow set — restoring a draft would reintroduce writable/hot-exit state + // for an agent-owned transcript. + const dirtyDraftContent = f.isDirty && f.readOnly !== true ? editorDrafts[f.id] : undefined arr.push({ filePath: f.filePath, relativePath: f.relativePath, @@ -135,6 +138,10 @@ export function buildEditorSessionData( language: f.language, isPreview: f.isPreview || undefined, runtimeEnvironmentId: f.runtimeEnvironmentId, + // Why: persist read-only only when true so pre-existing writable sessions + // stay writable on restore (absence is the writable default). + ...(f.readOnly === true ? { readOnly: true } : {}), + ...(f.readOnly === true && f.liveTail === true ? { liveTail: true } : {}), ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}), // Why: the edit baseline travels with the dirty draft so a restore can // re-derive a changed-on-disk conflict before autosave may overwrite an diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index d8738600d69..3265ad343b0 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -37,6 +37,7 @@ export type RuntimeReadableFileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string } export type RuntimeFileReadArgs = { @@ -45,6 +46,7 @@ export type RuntimeFileReadArgs = { relativePath?: string worktreeId?: string connectionId?: string + includeLocalLogMetadata?: boolean } export type RuntimeFileOperationArgs = { @@ -147,14 +149,15 @@ export async function readRuntimeFileContent({ filePath, relativePath, worktreeId, - connectionId + connectionId, + includeLocalLogMetadata }: RuntimeFileReadArgs): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!worktreeId) { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!canReadRelativeRuntimeFile(relativePath)) { throw new Error('Remote file is outside the owning runtime worktree') diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 760754294ef..cbdd62a728f 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4548,3 +4548,126 @@ describe('closeFile host mirroring', () => { expect(store.getState().openFiles).toHaveLength(0) }) }) + +describe('read-only editor tabs (AI Vault View Log)', () => { + const LOG_PATH = '/home/user/.claude/sessions/log.jsonl' + + const openReadOnlyLog = (store: StoreApi): void => + store.getState().openFile( + { + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + readOnly: true, + liveTail: true, + runtimeEnvironmentId: null + }, + { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } + ) + + it('creates a permanent read-only edit tab', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + filePath: LOG_PATH, + mode: 'edit', + readOnly: true, + liveTail: true, + isPreview: undefined, + runtimeEnvironmentId: null + }) + ) + }) + + it('bumps the reload nonce on repeated View Log of a clean read-only tab', () => { + const store = createEditorStore() + openReadOnlyLog(store) + expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBeUndefined() + + openReadOnlyLog(store) + expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(1) + }) + + it('keeps read-only sticky when the same path is opened writable (no silent upgrade)', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + store.getState().openFile({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + runtimeEnvironmentId: null + }) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.readOnly).toBe(true) + }) + + it('never flips an existing writable tab to read-only on View Log', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + runtimeEnvironmentId: null + }) + + openReadOnlyLog(store) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.readOnly).toBeUndefined() + }) + + it('markFileDirty and setEditorDraft hard no-op for read-only tabs', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + store.getState().markFileDirty(LOG_PATH, true) + store.getState().setEditorDraft(LOG_PATH, 'stray edit') + + expect(store.getState().openFiles[0]?.isDirty).toBe(false) + expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() + }) + + it('hydrates a persisted read-only tab clean and ignores any persisted dirty draft', () => { + const store = createEditorStore() + store.setState({ + worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] }, + folderWorkspaces: [] + } as never) + + store.getState().hydrateEditorSession({ + openFilesByWorktree: { + 'wt-1': [ + { + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + readOnly: true, + liveTail: true, + // Why: a corrupt/legacy session could carry a draft; hydrate must + // hard-strip it so the restored log can never come back writable. + dirtyDraftContent: 'should be ignored', + lastKnownDiskSignature: 'sig' + } + ] + } + } as never) + + const restored = store.getState().openFiles.find((f) => f.filePath === LOG_PATH) + expect(restored).toEqual( + expect.objectContaining({ readOnly: true, liveTail: true, isDirty: false, mode: 'edit' }) + ) + expect(restored?.pendingDiskBaselineVerification).toBeUndefined() + expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 2a63053f3e1..07de857fa33 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -282,6 +282,14 @@ export type OpenFile = { * tabs may be culled when they vanish from a later host snapshot; locally * opened tabs have no host counterpart and must survive snapshot syncs. */ mirroredFromRuntimeSession?: boolean + /** Why: orthogonal to `mode` — a `mode: 'edit'` tab that must never accept + * edits, dirty state, autosave, format, or rename. Used by AI Vault View Log + * so an agent-owned transcript cannot be mutated through editor write paths. + * Persisted only when true; absence is the writable default. */ + readOnly?: boolean + /** Why: live tail is explicit and only meaningful for a read-only local log; + * ordinary editor tabs and read-only snapshots keep their existing behavior. */ + liveTail?: boolean mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details' } @@ -1337,9 +1345,16 @@ function getWorktreeConnectionId(state: AppState, worktreeId: string): string | export const createEditorSlice: StateCreator = (set, get) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => - set((s) => ({ - editorDrafts: { ...s.editorDrafts, [fileId]: content } - })), + set((s) => { + // Why: read-only tabs (e.g. AI Vault View Log) must never accumulate an + // editor draft — a draft is the seed of dirty state, autosave, and a + // hot-exit restore that could write over an agent-owned transcript. + const file = s.openFiles.find((f) => f.id === fileId) + if (file?.readOnly === true) { + return s + } + return { editorDrafts: { ...s.editorDrafts, [fileId]: content } } + }), clearEditorDraft: (fileId) => set((s) => { if (!(fileId in s.editorDrafts)) { @@ -1681,6 +1696,10 @@ export const createEditorSlice: StateCreator = (s if (!needsExistingUpdate) { return activeResult } + // Why: `readOnly` is intentionally NOT in this override map. It is + // sticky: an existing tab keeps its own authority (`...f`). View Log + // never flips a writable tab to read-only, and an ordinary open never + // silently upgrades a read-only View Log tab to writable. return { openFiles: s.openFiles.map((f) => f.id === id @@ -2442,6 +2461,11 @@ export const createEditorSlice: StateCreator = (s if (!file) { return s } + // Why: read-only tabs can never become dirty; a mutation path that reached + // here (stray change/save callback) must hard no-op the integrity invariant. + if (file.readOnly === true) { + return s + } const needsPreviewClear = dirty && file.isPreview if (file.isDirty === dirty && !needsPreviewClear) { return s @@ -4337,7 +4361,11 @@ export const createEditorSlice: StateCreator = (s worktreeId, runtimeEnvironmentId: pf.runtimeEnvironmentId }) - if (pf.dirtyDraftContent !== undefined) { + // Why: read-only tabs (AI Vault View Log) must restore clean. Ignore + // any persisted dirty draft / baseline so a restored agent log can + // never come back writable or as a hot-exit draft to be saved. + const isReadOnly = pf.readOnly === true + if (!isReadOnly && pf.dirtyDraftContent !== undefined) { editorDrafts[id] = pf.dirtyDraftContent } openFiles.push({ @@ -4349,16 +4377,20 @@ export const createEditorSlice: StateCreator = (s // Re-detect on hydrate so newly-supported extensions like .ipynb // stop reopening as raw JSON/plain text after the upgrade. language: detectLanguage(pf.relativePath || pf.filePath), - isDirty: pf.dirtyDraftContent !== undefined, + isDirty: !isReadOnly && pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, - lastKnownDiskSignature: pf.lastKnownDiskSignature, + ...(isReadOnly ? { readOnly: true } : {}), + ...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}), + lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature, // Why: hard-suspends autosave until the restored-tab conflict scan // verifies disk against the baseline — an async race would let a // slow remote read lose to the autosave timer and clobber an // offline agent write. pendingDiskBaselineVerification: - pf.dirtyDraftContent !== undefined && pf.lastKnownDiskSignature !== undefined + !isReadOnly && + pf.dirtyDraftContent !== undefined && + pf.lastKnownDiskSignature !== undefined ? true : undefined, mode: 'edit' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e477f5a00d0..321940a15c8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1478,6 +1478,14 @@ function createFileApi(): NonNullable['fs']> { relativePath: file.relativePath }) }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, downloadFile: async () => { throw new Error('Remote file download is unavailable in paired web clients.') }, diff --git a/src/shared/local-log-tail-types.ts b/src/shared/local-log-tail-types.ts new file mode 100644 index 00000000000..b589ff96690 --- /dev/null +++ b/src/shared/local-log-tail-types.ts @@ -0,0 +1,26 @@ +export const LOCAL_LOG_TAIL_CHUNK_BYTES = 256 * 1024 + +export type LocalLogTailReadArgs = { + filePath: string + fromByteOffset: number + expectedIdentity?: string +} + +export type LocalLogTailReadResult = { + contentBase64: string + nextByteOffset: number + fileSize: number + fileIdentity: string + hasMore: boolean + reset: boolean +} + +export type LocalLogTailWatchArgs = { + filePath: string + subscriptionId: string +} + +export type LocalLogTailChangedPayload = { + subscriptionId: string + eventType: 'change' | 'rename' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f71f0601205..6079fa4fe9b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1027,6 +1027,11 @@ export type PersistedOpenFile = { /** Signature of the disk content the dirty draft is based on; lets restore * re-derive a changed-on-disk conflict from ground truth. */ lastKnownDiskSignature?: string + /** Why: a read-only tab (AI Vault View Log) must survive restart still + * read-only; persisted only when true so old sessions stay writable. */ + readOnly?: boolean + /** Opt-in streaming append for a read-only local log tab. */ + liveTail?: boolean } export type WorkspaceSessionState = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 22657617c10..ab5d354e017 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -162,7 +162,9 @@ const persistedOpenFileSchema = z.object({ isPreview: z.boolean().optional(), runtimeEnvironmentId: z.string().nullable().optional(), dirtyDraftContent: z.string().optional(), - lastKnownDiskSignature: z.string().optional() + lastKnownDiskSignature: z.string().optional(), + readOnly: z.boolean().optional(), + liveTail: z.boolean().optional() }) // ─── Browser ──────────────────────────────────────────────────────── From 4df945753028a4525c3468edc6c1eac89c66b1f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:24:27 -0700 Subject: [PATCH 025/138] Fix delayed terminal color-scheme replies leaking into fish (#8206) * fix(terminal): send mode 2031 replies immediately * docs(terminal): name mode 2031 color replies accurately --- .../terminal-pane/terminal-appearance.test.ts | 29 +++---- .../terminal-pane/terminal-appearance.ts | 31 +------- .../terminal-mode-2031-replies.test.ts | 68 +++++++++++++++++ .../terminal-mode-2031-replies.ts | 75 +++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 49 +++++------- 5 files changed, 177 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index b7e088ff4aa..d0c060122af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -6,10 +6,10 @@ import { applyTerminalAppearance, hexToRgba, installMode2031Handlers, - maybePushMode2031Flip, - mode2031SequenceFor, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' +import { maybePushMode2031Flip } from './terminal-mode-2031-replies' +import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' @@ -17,12 +17,14 @@ import type { TerminalViewAttributes } from '../../../../shared/terminal-view-at function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean sendInput: ReturnType boolean>> + sendInputImmediate: ReturnType boolean>> } { const connected = overrides?.connected ?? true const sendOk = overrides?.sendOk ?? true return { isConnected: () => connected, - sendInput: vi.fn<(data: string) => boolean>(() => sendOk) + sendInput: vi.fn<(data: string) => boolean>(() => sendOk), + sendInputImmediate: vi.fn<(data: string) => boolean>(() => sendOk) } } @@ -42,7 +44,7 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() expect(last.has(1)).toBe(false) }) @@ -54,8 +56,9 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(true) - expect(transport.sendInput).toHaveBeenCalledTimes(1) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n') + expect(transport.sendInput).not.toHaveBeenCalled() expect(last.get(1)).toBe('dark') }) @@ -70,7 +73,7 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'dark', transport, subs, last) maybePushMode2031Flip(1, 'dark', transport, subs, last) - expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) expect(last.get(1)).toBe('dark') }) @@ -83,7 +86,7 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'light', transport, subs, last) maybePushMode2031Flip(1, 'dark', transport, subs, last) - expect(transport.sendInput.mock.calls.map((c) => c[0])).toEqual([ + expect(transport.sendInputImmediate.mock.calls.map((c) => c[0])).toEqual([ '\x1b[?997;1n', '\x1b[?997;2n', '\x1b[?997;1n' @@ -99,11 +102,11 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() expect(last.has(1)).toBe(false) }) - it('leaves last-mode untouched when sendInput reports failure', () => { + it('leaves last-mode untouched when immediate input reports failure', () => { // So a reconnect / retry will re-emit on the next appearance pass. const transport = fakeTransport({ sendOk: false }) const subs = new Map([[1, true]]) @@ -112,7 +115,7 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) expect(last.has(1)).toBe(false) }) @@ -130,8 +133,8 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'dark', transportA, subs, last) // suppressed maybePushMode2031Flip(2, 'dark', transportB, subs, last) // flip - expect(transportA.sendInput).toHaveBeenCalledTimes(1) - expect(transportB.sendInput).toHaveBeenCalledTimes(2) + expect(transportA.sendInputImmediate).toHaveBeenCalledTimes(1) + expect(transportB.sendInputImmediate).toHaveBeenCalledTimes(2) expect(last.get(1)).toBe('dark') expect(last.get(2)).toBe('dark') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index a34ac215e66..d1af2c9f1af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -1,7 +1,6 @@ import type { IDisposable, IParser, ITheme } from '@xterm/xterm' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { GlobalSettings } from '../../../../shared/types' -import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { @@ -24,8 +23,7 @@ import { HEX_COLOR_RE } from '../../../../shared/color-validation' import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' - -export { mode2031SequenceFor } +import { maybePushMode2031Flip } from './terminal-mode-2031-replies' // Why Pick over a hand-rolled structural type: keeps the helper // tied to xterm's canonical signature so any upstream tightening (added @@ -112,33 +110,6 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ ] } -// Gate on actual mode flip so font/size/opacity tweaks — which also re-run -// applyTerminalAppearance — don't spam subscribed TUIs with CSI 997. The -// subscribe/last-mode maps are mutated in place so callers share state with -// the lifecycle hook's seed path. -export function maybePushMode2031Flip( - paneId: number, - mode: 'dark' | 'light', - transport: Pick, - paneMode2031: Map, - paneLastThemeMode: Map -): boolean { - if (!transport.isConnected()) { - return false - } - if (!paneMode2031.get(paneId)) { - return false - } - if (paneLastThemeMode.get(paneId) === mode) { - return false - } - if (!transport.sendInput(mode2031SequenceFor(mode))) { - return false - } - paneLastThemeMode.set(paneId, mode) - return true -} - export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') if (clean.length === 3) { diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts new file mode 100644 index 00000000000..ca88cb7fe8d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { pushMode2031SeedReply } from './terminal-mode-2031-replies' + +describe('pushMode2031SeedReply', () => { + function createHarness(connected: boolean): { + connected: { current: boolean } + subscribed: { current: boolean } + sendInput: ReturnType boolean>> + sendInputImmediate: ReturnType boolean>> + scheduled: (() => void)[] + recordMode: ReturnType + push: () => void + } { + const connectedState = { current: connected } + const subscribed = { current: true } + const sendInput = vi.fn<(data: string) => boolean>(() => true) + const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true) + const scheduled: (() => void)[] = [] + const recordMode = vi.fn() + const transport = { + isConnected: () => connectedState.current, + sendInput, + sendInputImmediate + } + return { + connected: connectedState, + subscribed, + sendInput, + sendInputImmediate, + scheduled, + recordMode, + push: () => + pushMode2031SeedReply(1, { + hasPane: () => true, + isSubscribed: () => subscribed.current, + getTransport: () => transport, + getMode: () => 'dark', + recordMode, + schedule: (callback) => scheduled.push(callback) + }) + } + } + + it('routes the color-scheme response through latency-critical input', () => { + const harness = createHarness(true) + + harness.push() + + expect(harness.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n') + expect(harness.sendInput).not.toHaveBeenCalled() + expect(harness.recordMode).toHaveBeenCalledWith(1, 'dark') + }) + + it('cancels a pre-connect retry after the program unsubscribes', () => { + const harness = createHarness(false) + + harness.push() + expect(harness.scheduled).toHaveLength(1) + + harness.subscribed.current = false + harness.connected.current = true + harness.scheduled.shift()?.() + + expect(harness.sendInputImmediate).not.toHaveBeenCalled() + expect(harness.sendInput).not.toHaveBeenCalled() + expect(harness.scheduled).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts new file mode 100644 index 00000000000..e30961692f8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts @@ -0,0 +1,75 @@ +import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { PtyTransport } from './pty-transport' + +const MODE_2031_CONNECT_RETRY_MS = 25 +const MODE_2031_CONNECT_ATTEMPTS = 8 + +type Mode2031ReplyTransport = Pick + +type Mode2031SeedReplyDeps = { + hasPane: (paneId: number) => boolean + isSubscribed: (paneId: number) => boolean + getTransport: (paneId: number) => Mode2031ReplyTransport | undefined + getMode: () => TerminalColorSchemeMode | null + recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void + schedule: (callback: () => void, delayMs: number) => void +} + +function sendMode2031Reply( + transport: Mode2031ReplyTransport, + mode: TerminalColorSchemeMode +): boolean { + // Why: fish stops reading mode-2031 replies quickly; remote input batching + // can otherwise deliver this terminal response after the shell regains input. + return transport.sendInputImmediate(mode2031SequenceFor(mode)) +} + +export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDeps): void { + let attempts = 0 + const send = (): void => { + // Why: a TUI can unsubscribe while the PTY is connecting; every delayed + // attempt must revalidate intent or its color reply can land at a shell prompt. + if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId)) { + return + } + const transport = deps.getTransport(paneId) + if (!transport?.isConnected()) { + attempts += 1 + if (attempts < MODE_2031_CONNECT_ATTEMPTS) { + deps.schedule(send, MODE_2031_CONNECT_RETRY_MS) + } + return + } + const mode = deps.getMode() + if (mode && sendMode2031Reply(transport, mode)) { + deps.recordMode(paneId, mode) + } + } + send() +} + +// Appearance updates include font and opacity changes, so only report actual +// color-mode flips to programs that still have mode 2031 enabled. +export function maybePushMode2031Flip( + paneId: number, + mode: TerminalColorSchemeMode, + transport: Mode2031ReplyTransport, + paneMode2031: Map, + paneLastThemeMode: Map +): boolean { + if (!transport.isConnected()) { + return false + } + if (!paneMode2031.get(paneId)) { + return false + } + if (paneLastThemeMode.get(paneId) === mode) { + return false + } + if (!sendMode2031Reply(transport, mode)) { + return false + } + paneLastThemeMode.set(paneId, mode) + return true +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 372a222e9ac..c39d97453e3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -58,11 +58,8 @@ import { import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' -import { - applyTerminalAppearance, - installMode2031Handlers, - mode2031SequenceFor -} from './terminal-appearance' +import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance' +import { pushMode2031SeedReply } from './terminal-mode-2031-replies' import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' @@ -604,34 +601,22 @@ export function useTerminalPaneLifecycle({ } const pushMode2031ForPane = (paneId: number): void => { - let attempts = 0 - const send = (): void => { - if (!managerRef.current?.getPanes().some((pane) => pane.id === paneId)) { - return + pushMode2031SeedReply(paneId, { + hasPane: (candidateId) => + managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true, + isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true, + getTransport: (candidateId) => paneTransportsRef.current.get(candidateId), + getMode: () => { + const currentSettings = settingsRef.current + return currentSettings + ? resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDarkRef.current).mode + : null + }, + recordMode: (candidateId, mode) => paneLastThemeModeRef.current.set(candidateId, mode), + schedule: (callback, delayMs) => { + window.setTimeout(callback, delayMs) } - const transport = paneTransportsRef.current.get(paneId) - if (!transport?.isConnected()) { - // Why: TUIs can subscribe before pty:spawn resolves. Retry briefly so - // the recorded subscription still receives the initial dark/light seed. - attempts += 1 - if (attempts < 8) { - window.setTimeout(send, 25) - } - return - } - const currentSettings = settingsRef.current - if (!currentSettings) { - return - } - const { mode } = resolveEffectiveTerminalAppearance( - currentSettings, - systemPrefersDarkRef.current - ) - if (transport.sendInput(mode2031SequenceFor(mode))) { - paneLastThemeModeRef.current.set(paneId, mode) - } - } - send() + }) } // Initialize PaneManager instance once From e3f0b99f4abb80a4ae9e930786713d49d2be0baf Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:31:53 -0300 Subject: [PATCH 026/138] fix: detect noisy Windows ARM64 SSH platforms (#7965) * fix: detect noisy Windows ARM64 SSH platforms - Scan remote platform probe output line-by-line for supported OS/arch markers. - Handle PowerShell/OpenSSH first-use CLIXML or banner noise before Windows ARM64. - Add regression coverage for noisy Windows ARM64 detection. * fix: harden SSH platform probe markers --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- .../ssh-relay-cross-version-isolation.test.ts | 2 +- src/main/ssh/ssh-relay-deploy.test.ts | 61 ++++++++++--------- .../ssh/ssh-relay-native-deps-install.test.ts | 16 +++-- .../ssh/ssh-remote-platform-detection.test.ts | 51 ++++++++++++++-- src/main/ssh/ssh-remote-platform-detection.ts | 43 +++++++++---- src/main/ssh/ssh-remote-platform.test.ts | 5 +- 6 files changed, 123 insertions(+), 55 deletions(-) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index c426e616fed..a29801c3632 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -98,7 +98,7 @@ describe('cross-version isolation', () => { // $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec), // npm install, finalize, socket probe, socket poll, then GC scan. const responses: string[] = [ - 'Linux x86_64', // uname -sm + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) '', // mkdir -p remoteRelayDir (v2) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index ec825cdc4b6..33467819035 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,7 +42,7 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), - execCommand: vi.fn().mockResolvedValue('Linux x86_64') + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) vi.mock('./ssh-remote-node-resolution', () => ({ @@ -123,7 +123,7 @@ describe('deployAndLaunchRelay', () => { it('calls exec to detect remote platform', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -131,13 +131,16 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(mockExecCommand).toHaveBeenCalledWith(conn, 'uname -sm') + expect(mockExecCommand).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('reports progress via callback', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -153,7 +156,7 @@ describe('deployAndLaunchRelay', () => { it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -167,7 +170,7 @@ describe('deployAndLaunchRelay', () => { it('resolves node concurrently with remote home, not after the install-state chain', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let markNodeResolutionStarted: () => void = () => {} const nodeResolutionStarted = new Promise((resolve) => { @@ -221,7 +224,7 @@ describe('deployAndLaunchRelay', () => { const conn = makeMockConnection() vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeProbeStarted: () => void = () => {} const remoteHomeProbeStartedPromise = new Promise((resolve) => { @@ -274,7 +277,7 @@ describe('deployAndLaunchRelay', () => { fallbackInstallStateCompleted = true return true }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME @@ -302,7 +305,7 @@ describe('deployAndLaunchRelay', () => { throw sessionLimitError }) .mockResolvedValueOnce(true) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe @@ -324,7 +327,7 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) const nodeError = new Error('Node.js not found on remote host') vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) @@ -346,7 +349,7 @@ describe('deployAndLaunchRelay', () => { }) }) }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME const timedDeploy = Promise.race([ @@ -370,7 +373,7 @@ describe('deployAndLaunchRelay', () => { const installError = new Error('permission denied while checking relay install') vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) @@ -384,7 +387,7 @@ describe('deployAndLaunchRelay', () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeSettled = false mockExecCommand.mockReturnValueOnce( @@ -420,7 +423,7 @@ describe('deployAndLaunchRelay', () => { it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -439,7 +442,7 @@ describe('deployAndLaunchRelay', () => { it('allows an unlimited SSH disconnect grace window', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -458,7 +461,7 @@ describe('deployAndLaunchRelay', () => { it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -477,7 +480,7 @@ describe('deployAndLaunchRelay', () => { it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -522,12 +525,12 @@ describe('deployAndLaunchRelay', () => { const connB = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) mockExecCommand - .mockResolvedValueOnce('Linux x86_64') // uname A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe A .mockResolvedValueOnce('/home/user') // $HOME A .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe A .mockResolvedValueOnce('DEAD') // probe A .mockResolvedValueOnce('READY') // poll A - .mockResolvedValueOnce('Linux x86_64') // uname B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe B .mockResolvedValueOnce('/home/user') // $HOME B .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe B .mockResolvedValueOnce('DEAD') // probe B @@ -561,8 +564,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe @@ -607,8 +610,8 @@ describe('deployAndLaunchRelay', () => { onClose: vi.fn() }) mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe yet @@ -654,8 +657,8 @@ describe('deployAndLaunchRelay', () => { const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker @@ -682,8 +685,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname A - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe A @@ -691,8 +694,8 @@ describe('deployAndLaunchRelay', () => { .mockResolvedValueOnce('') .mockResolvedValueOnce('READY') .mockResolvedValueOnce('') // persist active pipe A - .mockRejectedValueOnce(new Error('uname not found')) // uname B - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe B diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 2b63b3a2d50..4d2f92eb947 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -152,7 +152,7 @@ function makeExecResponses(opts: { // build toolchain — no chmod/probe/launch slots are reached. if (opts.npmInstall !== 'ok') { return [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -171,7 +171,7 @@ function makeExecResponses(opts: { ? { reject: 'cd: no such file or directory' } : opts.probe const slots: ExecResponse[] = [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -530,7 +530,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') const conn = makeMockConnection(sftpCapture) feed([ - 'Windows AMD64', + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', 'C:\\Users\\u', '', // mkdir remoteDir '', // npm install native deps @@ -605,7 +605,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) feed([ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', // first native-deps probe before lock 'MISSING', // re-probe after lock @@ -632,7 +632,13 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { it('does not mutate an existing relay dir when required native deps are present', async () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) - feed(['Linux x86_64', '/home/u', 'ORCA-NATIVE-DEPS-OK', 'DEAD', 'READY']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) await deployAndLaunchRelay(conn) diff --git a/src/main/ssh/ssh-remote-platform-detection.test.ts b/src/main/ssh/ssh-remote-platform-detection.test.ts index a8ffd416830..71fc4e7c0cd 100644 --- a/src/main/ssh/ssh-remote-platform-detection.test.ts +++ b/src/main/ssh/ssh-remote-platform-detection.test.ts @@ -11,13 +11,18 @@ const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detecti const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' +} + describe('detectRemoteHostPlatform', () => { beforeEach(() => { execCommandMock.mockReset() }) it('detects POSIX hosts from uname output', async () => { - execCommandMock.mockResolvedValueOnce('Linux x86_64\n') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'linux-x64', @@ -25,13 +30,16 @@ describe('detectRemoteHostPlatform', () => { arch: 'x64', pathFlavor: 'posix' }) - expect(execCommandMock).toHaveBeenCalledWith(conn, 'uname -sm') + expect(execCommandMock).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('falls back to PowerShell detection for Windows remotes', async () => { execCommandMock .mockRejectedValueOnce(new Error('uname unavailable')) - .mockResolvedValueOnce('Windows AMD64\r\n') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64\r\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -45,17 +53,50 @@ describe('detectRemoteHostPlatform', () => { expect.stringContaining('powershell.exe'), { wrapCommand: false } ) + const command = execCommandMock.mock.calls[1]?.[1] ?? '' + expect(decodePowerShellCommand(command)).toContain( + 'Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)' + ) + }) + + it('ignores untagged platforms before the tagged Windows ARM64 result', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('uname unavailable')) + .mockResolvedValueOnce( + 'Linux x86_64\r\nWindows AMD64\r\n#< CLIXML\r\n' + + '__ORCA_REMOTE_PLATFORM__ Windows ARM64\r\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'win32-arm64', + os: 'win32', + arch: 'arm64', + pathFlavor: 'windows' + }) + }) + + it('ignores a marker concatenated to unterminated startup noise', async () => { + execCommandMock.mockResolvedValueOnce( + 'startup noise__ORCA_REMOTE_PLATFORM__ Linux x86_64\n' + + '__ORCA_REMOTE_PLATFORM__ Linux arm64\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'linux-arm64' + }) }) it('returns null when neither probe yields a supported platform', async () => { - execCommandMock.mockResolvedValueOnce('Linux').mockResolvedValueOnce('FreeBSD x86_64') + execCommandMock + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ FreeBSD x86_64') await expect(detectRemoteHostPlatform(conn)).resolves.toBeNull() }) it('does not use whitespace regex splitting for remote platform output', async () => { const splitSpy = vi.spyOn(String.prototype, 'split') - execCommandMock.mockResolvedValueOnce('Darwin arm64 extra') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64 extra') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64' diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f31f53f4ec4..f72b5384335 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -1,10 +1,15 @@ import type { SshConnection } from './ssh-connection' -import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../../shared/process-output-field-scanner' import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' +const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' + export async function detectRemoteHostPlatform( conn: SshConnection ): Promise { @@ -18,12 +23,12 @@ export async function detectRemoteHostPlatform( async function detectUnamePlatform(conn: SshConnection): Promise { try { - const output = await execCommand(conn, 'uname -sm') - const parts = getProcessOutputFields(output, 2) - if (parts.length < 2) { - return null - } - return parseUnameToRelayPlatform(parts[0], parts[1]) + const output = await execCommand( + conn, + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + ) + return parseRemotePlatformOutput(output) } catch { return null } @@ -35,15 +40,27 @@ async function detectWindowsPlatform(conn: SshConnection): Promise { describe('detectRemoteHostPlatform', () => { it('uses uname when the remote is POSIX', async () => { - vi.mocked(execCommand).mockResolvedValueOnce('Darwin arm64') + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64', @@ -46,7 +46,7 @@ describe('detectRemoteHostPlatform', () => { it('falls back to PowerShell when uname is unavailable on Windows', async () => { vi.mocked(execCommand) .mockRejectedValueOnce(new Error('uname not recognized')) - .mockResolvedValueOnce('Windows AMD64') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -59,5 +59,6 @@ describe('detectRemoteHostPlatform', () => { expect(script).toContain('$arch = $env:PROCESSOR_ARCHITECTURE') expect(script).toContain('try { $runtimeArch =') expect(script).toContain('catch {}') + expect(script).toContain('Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)') }) }) From c869891b3749b1808dc99b083a118dce4aff3e56 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 10 Jul 2026 22:43:12 -0700 Subject: [PATCH 027/138] test(e2e): defer large repo cleanup until shutdown (#8228) --- tests/e2e/helpers/orca-app.ts | 27 +++++++- .../source-control-large-file-count.spec.ts | 63 ++++++++++++++----- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index eb49dfeba5e..4d7f1b9ef37 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -32,6 +32,7 @@ import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication + registerPostElectronShutdownCleanup: (cleanup: () => Promise) => void sharedPage: Page orcaPage: Page // Why: every fresh userData dir paints the first-launch onboarding overlay @@ -145,12 +146,36 @@ export const test = base.extend({ { scope: 'worker' } ], + // Why: Windows keeps watched worktrees locked until Electron and its + // detached test daemons exit. Tests register fixture cleanup here so it runs + // after electronApp teardown instead of masking the real assertion failure. + registerPostElectronShutdownCleanup: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here. + async ({}, provideFixture) => { + const cleanups: (() => Promise)[] = [] + await provideFixture((cleanup) => cleanups.push(cleanup)) + for (const cleanup of cleanups.toReversed()) { + await cleanup() + } + }, + { scope: 'test' } + ], + // Test-scoped: one Electron app per test electronApp: async ( - { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + { + dismissOnboarding, + launchEnv, + orcaAppExtraEnv, + orcaAppExtraArgs, + registerPostElectronShutdownCleanup + }, provideFixture, testInfo ) => { + // Establish fixture ordering: registered path cleanup must run only after + // this Electron fixture has released watchers, terminals, and daemons. + void registerPostElectronShutdownCleanup const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts index 2eff3d2845f..c54a9651c43 100644 --- a/tests/e2e/source-control-large-file-count.spec.ts +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -102,17 +102,36 @@ async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab)) .toBe('source-control') + await expect(orcaPage.getByRole('button', { name: 'Filter files by name' })).toBeVisible() return worktreeId } +async function unregisterLargeFileCountRepos( + orcaPage: Page, + repoPaths: readonly string[] +): Promise { + // Why: remove disposable projects through the product so their terminals + // and watcher subscriptions begin shutting down before Electron teardown. + for (const repoPath of repoPaths) { + await orcaPage.evaluate(async (pathToRepo) => { + const store = window.__store + const repo = store?.getState().repos.find((entry) => entry.path === pathToRepo) + if (repo) { + await store?.getState().removeProject(repo.id) + } + }, repoPath) + } +} + /** * Drives the exact pipeline the panel's poll uses (api.git.status → * setGitStatus) so results are deterministic even while the built-in 3s poll @@ -242,7 +261,8 @@ test.describe('Source Control large file count (#8013)', () => { test('thousands of untracked files under the status cap stay responsive', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '9500') @@ -255,6 +275,7 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles, untrackedFileBytes }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -286,17 +307,19 @@ test.describe('Source Control large file count (#8013)', () => { ) } } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('thousands of modified tracked files under the status cap stay responsive', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const modifiedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '5000') const fixture = createLargeFileCountRepo({ trackedFiles: modifiedFiles, modifiedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -319,17 +342,19 @@ test.describe('Source Control large file count (#8013)', () => { expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('a change set over the status cap degrades to the too-many-changes state', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = DEFAULT_GIT_STATUS_LIMIT + 1_000 const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -361,11 +386,14 @@ test.describe('Source Control large file count (#8013)', () => { ) expect(hugeState).not.toBeNull() } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) - test('untracked line-stat cache stays effective above 2,048 files', async ({ orcaPage }) => { + test('untracked line-stat cache stays effective above 2,048 files', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }) => { test.setTimeout(600_000) // Why: the untracked line-stat cache historically capped at 2,048 entries // with FIFO eviction, so a sequential scan over more files evicted every @@ -380,6 +408,7 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles: 2_000, untrackedFileBytes: fileBytes }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(smallRepo.repoPath)) let largeRepo: ReturnType | null = null try { largeRepo = createLargeFileCountRepo({ @@ -387,6 +416,8 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles: 4_000, untrackedFileBytes: fileBytes }) + const largeRepoPath = largeRepo.repoPath + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(largeRepoPath)) await waitForSessionReady(orcaPage) const warmRescanPerFileMs = async (repoPath: string, files: number): Promise => { @@ -407,20 +438,22 @@ test.describe('Source Control large file count (#8013)', () => { ) expect(largePerFileMs).toBeLessThan(smallPerFileMs * 2) } finally { - await removeLargeFileCountRepo(smallRepo.repoPath) - if (largeRepo) { - await removeLargeFileCountRepo(largeRepo.repoPath) - } + await unregisterLargeFileCountRepos(orcaPage, [ + smallRepo.repoPath, + ...(largeRepo ? [largeRepo.repoPath] : []) + ]) } }) test('a large clean repo (tracked files only) loads instantly', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const trackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '15000') const fixture = createLargeFileCountRepo({ trackedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -440,7 +473,7 @@ test.describe('Source Control large file count (#8013)', () => { expect(measurement.entryCount).toBe(0) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) }) From 4f7fe78921a7c016f78c8dd266dffe17682f25cb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:59 -0700 Subject: [PATCH 028/138] perf(mobile): stop polling live worktree names (#8051) * perf(mobile): replace worktree name polling with events * fix(worktrees): push rename invalidation to remote clients worktrees:updateMeta deliberately skips the renderer notifier (PR #209), but paired mobile clients no longer poll for titles, so a manual rename would never reach them. Emit the remote-only worktreesChanged client event (with resolved-cache invalidation), gated on displayName so per-click isUnread writes stay event-free. Co-authored-by: Orca * test(worktrees): add missing runtimeStub type member for typecheck Co-authored-by: Orca * fix(worktrees): derive rename event repoId with the shared non-throwing parser getRepoIdFromWorktreeId matches the mobile client's event filter exactly and cannot throw after the meta write already persisted. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../session/use-live-worktree-name.test.ts | 169 ++++++++++++++++++ mobile/src/session/use-live-worktree-name.ts | 85 ++++++++- .../rpc-client-runtime-events.test.ts | 135 ++++++++++++++ mobile/src/transport/rpc-client.ts | 43 ++++- src/main/ipc/worktrees.test.ts | 22 ++- src/main/ipc/worktrees.ts | 13 +- src/main/runtime/orca-runtime.ts | 8 + 7 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 mobile/src/session/use-live-worktree-name.test.ts create mode 100644 mobile/src/transport/rpc-client-runtime-events.test.ts diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts new file mode 100644 index 00000000000..97927c6b7d9 --- /dev/null +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useLiveWorktreeName } from './use-live-worktree-name' + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(effect, [effect]) + } + } +}) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +describe('useLiveWorktreeName request volume', () => { + let renderer: ReactTestRenderer | null = null + let eventListener: ((payload: unknown) => void) | null = null + const unsubscribeStream = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + eventListener = listener + return unsubscribeStream + } + ) + const client = { sendRequest, subscribe } as unknown as RpcClient + + async function mountHarness(): Promise { + function Harness(): null { + useLiveWorktreeName({ + client, + connState: 'connected', + routeName: 'Route name', + worktreeId: 'repo-1::/worktree' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + } + + async function emitEvent(payload: unknown): Promise { + await act(async () => { + eventListener?.(payload) + await Promise.resolve() + await Promise.resolve() + }) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + eventListener = null + sendRequest.mockClear().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + subscribe.mockClear() + unsubscribeStream.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('cuts thirty-second idle request volume from eleven calls to one', async () => { + await mountHarness() + expect(subscribe).toHaveBeenCalledWith( + 'runtime.clientEvents.subscribe', + null, + expect.any(Function) + ) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + // The retired 3s interval made one initial request plus ten idle ticks here. + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-2' }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-1' }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps polling when an older runtime rejects the event stream', async () => { + await mountHarness() + await emitEvent({ type: 'error', message: 'Unknown method' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(9_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(4) + }) + + it('retries a failed initial read even after the event stream is ready', async () => { + sendRequest.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('refreshes once when a reconnected stream becomes ready again', async () => { + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-2' }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('unsubscribes when the screen loses focus', async () => { + await mountHarness() + act(() => renderer?.unmount()) + renderer = null + + expect(unsubscribeStream).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index 665fcea3a1e..bd21ec8f156 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useState } from 'react' import { useFocusEffect } from 'expo-router' +import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' +import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 + type Params = { client: RpcClient | null connState: ConnectionState @@ -24,12 +28,27 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } return } let stale = false - const refreshWorktreeName = async () => { + let eventStreamReady = false + let hasSuccessfulRefresh = false + let fallbackInterval: ReturnType | null = null + let refreshGeneration = 0 + const repoId = getRepoIdFromWorktreeId(worktreeId) + + const stopFallbackPoll = (): void => { + if (fallbackInterval !== null) { + clearInterval(fallbackInterval) + fallbackInterval = null + } + } + const refreshWorktreeName = async (): Promise => { + // Why: an event-driven refresh can overtake a slow fallback request; + // only the newest read may publish or stop the retry poll. + const generation = ++refreshGeneration try { const response = await client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` }) - if (stale || !response.ok) { + if (stale || generation !== refreshGeneration || !response.ok) { return } const result = (response as RpcSuccess).result as { @@ -41,17 +60,73 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } if (liveName) { setWorktreeName((current) => (current === liveName ? current : liveName)) } + hasSuccessfulRefresh = true + if (eventStreamReady) { + stopFallbackPoll() + } } catch { // Non-fatal: the route param remains a usable label until the next refresh. } } + + const startFallbackPoll = (): void => { + if (stale || fallbackInterval !== null) { + return + } + fallbackInterval = setInterval( + () => void refreshWorktreeName(), + WORKTREE_NAME_FALLBACK_POLL_MS + ) + } + const invalidateAndRefresh = (): void => { + hasSuccessfulRefresh = false + startFallbackPoll() + void refreshWorktreeName() + } + + startFallbackPoll() + const unsubscribe = client.subscribe( + 'runtime.clientEvents.subscribe', + null, + (payload: unknown) => { + if (stale || !payload || typeof payload !== 'object') { + return + } + const event = payload as RuntimeClientEventStreamMessage | { type: 'error' } + if (event.type === 'ready') { + const replayedAfterReconnect = eventStreamReady + eventStreamReady = true + if (hasSuccessfulRefresh) { + stopFallbackPoll() + } + if (replayedAfterReconnect) { + // Why: client events are not queued while disconnected, so replay + // readiness must re-read the title once to close that event gap. + invalidateAndRefresh() + } + return + } + if (event.type === 'end' || event.type === 'error') { + eventStreamReady = false + startFallbackPoll() + return + } + if ( + event.type === 'reposChanged' || + (event.type === 'worktreesChanged' && event.repoId === repoId) + ) { + invalidateAndRefresh() + } + } + ) // Why: route params are only an entry hint. The desktop/runtime owns - // displayName, including task-generated names that may settle after open. + // displayName. Modern runtimes push invalidations; the poll remains only + // until that stream proves available, preserving older-runtime behavior. void refreshWorktreeName() - const interval = setInterval(() => void refreshWorktreeName(), 3000) return () => { stale = true - clearInterval(interval) + stopFallbackPoll() + unsubscribe() } }, [client, connState, worktreeId]) ) diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts new file mode 100644 index 00000000000..d18739423f7 --- /dev/null +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class RuntimeEventTestSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = RuntimeEventTestSocket.CONNECTING + readonly OPEN = RuntimeEventTestSocket.OPEN + readonly CLOSING = RuntimeEventTestSocket.CLOSING + readonly CLOSED = RuntimeEventTestSocket.CLOSED + + readyState = RuntimeEventTestSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + sent: string[] = [] + + constructor(readonly endpoint: string) { + sockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + close(): void { + this.readyState = RuntimeEventTestSocket.CLOSED + this.onclose?.() + } + + open(): void { + this.readyState = RuntimeEventTestSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +type SentRequest = { id: string; method: string; params?: unknown } + +const sockets: RuntimeEventTestSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] { + return socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest) + .filter((request) => request.method === method) +} + +function connectReadyClient(): { client: RpcClient; socket: RuntimeEventTestSocket } { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = sockets[0]! + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + return { client, socket } +} + +function emitReady( + socket: RuntimeEventTestSocket, + requestId: string, + subscriptionId: string +): void { + socket.receive( + `encrypted:${JSON.stringify({ + id: requestId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId }, + _meta: { runtimeId: 'r1' } + })}` + ) +} + +describe('runtime client-event stream disposal', () => { + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + globalThis.WebSocket = originalWebSocket + vi.useRealTimers() + }) + + it('unsubscribes a stream disposed after ready', () => { + const { client, socket } = connectReadyClient() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, () => {}) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + emitReady(socket, request.id, 'runtime-events:test') + + unsubscribe() + + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:test' } }) + ]) + client.close() + }) + + it('keeps a tombstone and unsubscribes a stream disposed before ready', () => { + const { client, socket } = connectReadyClient() + const listener = vi.fn() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, listener) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + + unsubscribe() + emitReady(socket, request.id, 'runtime-events:late') + + expect(listener).not.toHaveBeenCalled() + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:late' } }) + ]) + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index b9d0c990261..014bea955cd 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -567,10 +567,10 @@ export function connect( const stream = streamListeners.get(response.id) if (stream && response.ok) { const result = (response as RpcSuccess).result - if (isBrowserScreencastReadyResult(result)) { + if (isStreamingSubscriptionReadyResult(result)) { stream.subscriptionId = result.subscriptionId if (stream.cancelled) { - sendBrowserScreencastUnsubscribe(result.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(response.id) return } @@ -947,8 +947,21 @@ export function connect( if (pendingBrowserScreencastRequestId === id) { pendingBrowserScreencastRequestId = null } + disposeServerSubscriptionStream(id, stream) + } + + function disposeRuntimeClientEventsStream(id: string): void { + const stream = streamListeners.get(id) + if (!stream || stream.method !== 'runtime.clientEvents.subscribe') { + return + } + disposeServerSubscriptionStream(id, stream) + } + + function disposeServerSubscriptionStream(id: string, stream: StreamRequest): void { + stream.cancelled = true if (stream.subscriptionId) { - sendBrowserScreencastUnsubscribe(stream.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(id) return } @@ -1022,6 +1035,24 @@ export function connect( }) } + function sendServerSubscriptionUnsubscribe(stream: StreamRequest): void { + if (!stream.subscriptionId) { + return + } + if (stream.method === 'browser.screencast') { + sendBrowserScreencastUnsubscribe(stream.subscriptionId) + return + } + if (stream.method === 'runtime.clientEvents.subscribe') { + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'runtime.clientEvents.unsubscribe', + params: { subscriptionId: stream.subscriptionId } + }) + } + } + openConnection() return { @@ -1120,6 +1151,10 @@ export function connect( disposeBrowserScreencastStream(id) return } + if (stream?.method === 'runtime.clientEvents.subscribe') { + disposeRuntimeClientEventsStream(id) + return + } if (stream?.method === 'terminal.subscribe') { // Why: the runtime registers cleanup under the composite key // `${terminal}:${clientId}` so two phones subscribing to the same @@ -1243,7 +1278,7 @@ function isTerminalSubscribedResult( ) } -function isBrowserScreencastReadyResult( +function isStreamingSubscriptionReadyResult( value: unknown ): value is { type: 'ready'; subscriptionId: string } { return ( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8e128912517..a278656ee09 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -282,6 +282,7 @@ describe('registerWorktreeHandlers', () => { resolveManagedMrBase: ReturnType createTerminal: ReturnType splitTerminal: ReturnType + notifyWorktreesChangedForRemoteClients: ReturnType } beforeEach(() => { @@ -490,7 +491,8 @@ describe('registerWorktreeHandlers', () => { handle: 'term-setup', tabId: 'tab-startup', paneRuntimeId: -1 - }) + }), + notifyWorktreesChangedForRemoteClients: vi.fn() } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -742,6 +744,24 @@ describe('registerWorktreeHandlers', () => { expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('pushes a remote-client invalidation for renames but not read-state updates', () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { isUnread: false } + }) + // Why: per-click isUnread writes must stay event-free (PR #209), while a + // rename must reach paired remote clients that no longer poll for titles. + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled() + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { displayName: 'Renamed workspace' } + }) + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') + }) + it('does not trust renderer-authored automation provenance during local create', async () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) listWorktreesMock.mockResolvedValue([ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 92e626b2e3a..4128cbe69de 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -150,7 +150,10 @@ import { } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId +} from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { getLocalProjectGitExecOptions, @@ -2017,6 +2020,14 @@ export function registerWorktreeHandlers( // sortEpoch and reorders the sidebar — the exact bug PR #209 tried // to fix (clicking a card would clear isUnread → updateMeta → // worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort). + if (args.updates.displayName !== undefined) { + // Why: paired remote clients have no optimistic copy of the rename and + // no longer poll for titles, so push the remote-only invalidation. + // Gated on displayName to keep isUnread-per-click updates event-free. + // getRepoIdFromWorktreeId matches the mobile client's event filter and + // never throws after the meta write already succeeded. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } return meta } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index cd1fe7be87f..0ed4215aa8f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2979,6 +2979,14 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) } + // Why: renderer-initiated meta updates intentionally skip the renderer + // notifier (the renderer already applied them optimistically), but remote + // clients hold no optimistic copy and need the invalidation event. + notifyWorktreesChangedForRemoteClients(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, From 6a176b89fc3844848cb6519e1f0cc57d60d005af Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:45:32 -0700 Subject: [PATCH 029/138] fix(source-control): keep push enabled for same-repo linked review with real upstream (#8202) * fix(source-control): keep push enabled for same-repo linked review with real upstream Push/Force Push (and Pull/Sync/Publish) were wrongly disabled when a branch had a genuine git upstream but an open linked PR whose push target had not yet hydrated. `hasUsableHostedReviewPushTarget` hard-returned false for any resolvable review link without a hydrated `pushTarget`, so `resolveHostedReviewActionUpstreamStatus` synthesized `hasUpstream:false` and the whole remote menu treated the branch as unpublished. A same-repo review's head IS the checked-out branch, so a real upstream already tracking that branch is that head and is safe to use before the resolver hydrates a target. Treat that upstream as usable; keep blocking the fork-head/cross-repo case (upstream tracks a differently-named head) and the no-upstream case until the resolver proves the real target. Provider-agnostic (GitHub PR / GitLab MR) and covers the SSH linked-review path. Adds regression tests to source-control-hosted-review-push-target.test.ts and a composed pipeline test in source-control-dropdown-items.test.ts. * docs(source-control): trim verbose comments on linked-review push-target fix Co-authored-by: Orca * refactor(source-control): use canonical gitRefTargetsBranchName for linked-review upstream match Replace the hand-rolled upstreamTracksBranch leaf-split with the shared gitRefTargetsBranchName primitive, which trims and rejects malformed remote-qualified refs. The remote stays unknown until the resolver hydrates the push target, so this remains a branch-leaf match; the strict remote+branch check takes over once the target is known. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../right-sidebar/SourceControl.tsx | 3 +- .../source-control-dropdown-items.test.ts | 80 +++++++++++++++- ...-control-hosted-review-push-target.test.ts | 92 ++++++++++++++++++- ...ource-control-hosted-review-push-target.ts | 15 ++- 4 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 22e3878848b..6ab53ec36ed 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1676,7 +1676,8 @@ function SourceControlInner(): React.JSX.Element { const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ pushTarget: activeWorktree?.pushTarget, upstreamStatus: remoteStatus, - hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink + hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink, + branchName }) const hostedReviewStateForActions = resolveHostedReviewStateForActions({ hostedReviewState: hostedReview?.state ?? null, diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 44aeb8e7bbc..86af5bafdb7 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { resolveDropdownItems, type DropdownActionInputs } from './source-control-dropdown-items' +import { + resolveDropdownItems, + type DropdownActionInputs, + type DropdownItem +} from './source-control-dropdown-items' +import { + hasUsableHostedReviewPushTarget, + resolveHostedReviewActionUpstreamStatus +} from './source-control-hosted-review-push-target' // Why: a shared defaults object keeps each case row terse while making the // "this is the one knob that differs from the baseline" intent obvious. @@ -768,3 +776,73 @@ describe('resolveDropdownItems', () => { expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') }) }) + +// Why: PR #8196 — drive the real push-target resolution the component uses so +// the whole chain stays regression-proof. +describe('resolveDropdownItems with an unhydrated linked-review push target', () => { + function pipeline(args: { + branchName: string + upstreamStatus: DropdownActionInputs['upstreamStatus'] + }): Record { + const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ + // pushTarget intentionally omitted: the resolver has not hydrated it yet. + hasResolvableHostedReviewPushTargetLink: true, + branchName: args.branchName, + upstreamStatus: args.upstreamStatus + }) + const upstreamStatus = resolveHostedReviewActionUpstreamStatus({ + hasHostedReviewLink: true, + hasResolvableHostedReviewPushTargetLink: true, + hostedReviewState: 'open', + isHostedReviewStateLoading: false, + canUseHostedReviewPushTarget, + upstreamStatus: args.upstreamStatus + }) + const items = resolveDropdownItems( + inputs({ + stagedCount: 1, + hasMessage: true, + branchCommitsAhead: 7, + prState: 'open', + upstreamStatus, + canPushLinkedReviewWithoutUpstream: canUseHostedReviewPushTarget + }) + ) + return Object.fromEntries( + items.filter((e): e is DropdownItem => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + } + + it('enables Push and Force Push when the real upstream is the same-repo review head', () => { + const byKind = pipeline({ + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/mobile-resume-suspected-fixes', + ahead: 7, + behind: 2 + } + }) + expect(byKind.push.disabled).toBe(false) + expect(byKind.push.title).not.toBe('Linked review branch target is unavailable') + expect(byKind.force_push.disabled).toBe(false) + expect(byKind.pull.disabled).toBe(false) + expect(byKind.sync.disabled).toBe(false) + expect(byKind.publish.disabled).toBe(true) + }) + + it('still blocks Push when the real upstream is an unrelated fork/helper head', () => { + const byKind = pipeline({ + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/helper-branch', + ahead: 1, + behind: 0 + } + }) + expect(byKind.push.disabled).toBe(true) + expect(byKind.push.title).toBe('Linked review branch target is unavailable') + expect(byKind.force_push.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts index 8e732f68fff..b65a6f8e35d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts @@ -136,7 +136,12 @@ describe('hasPositiveHostedReviewNumberLink', () => { expect(hasPositiveHostedReviewNumberLink({ linkedBitbucketPR: 34 })).toBe(true) expect(hasPositiveHostedReviewNumberLink({ linkedAzureDevOpsPR: 56 })).toBe(true) expect(hasPositiveHostedReviewNumberLink({ linkedGiteaPR: 78 })).toBe(true) - expect(hasPositiveHostedReviewNumberLink({ linkedGitHubPR: 0, linkedGitLabMR: -1 })).toBe(false) + expect( + hasPositiveHostedReviewNumberLink({ + linkedGitHubPR: 0, + linkedGitLabMR: -1 + }) + ).toBe(false) expect(hasPositiveHostedReviewNumberLink({ linkedGitHubPR: Number.NaN })).toBe(false) expect(hasPositiveHostedReviewNumberLink({})).toBe(false) }) @@ -187,18 +192,33 @@ describe('hasUsableHostedReviewPushTarget', () => { expect( hasUsableHostedReviewPushTarget({ pushTarget: { remoteName: 'fork', branchName: 'feature' }, - upstreamStatus: { hasUpstream: true, upstreamName: 'fork/feature', ahead: 1, behind: 0 } + upstreamStatus: { + hasUpstream: true, + upstreamName: 'fork/feature', + ahead: 1, + behind: 0 + } }) ).toBe(true) expect( hasUsableHostedReviewPushTarget({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0, hasConfiguredPushTarget: true } + upstreamStatus: { + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + } }) ).toBe(true) expect( hasUsableHostedReviewPushTarget({ hasResolvableHostedReviewPushTargetLink: true, - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0, hasConfiguredPushTarget: true } + upstreamStatus: { + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + } }) ).toBe(false) expect( @@ -209,4 +229,68 @@ describe('hasUsableHostedReviewPushTarget', () => { ).toBe(false) expect(hasUsableHostedReviewPushTarget({ upstreamStatus: unrelatedUpstream })).toBe(false) }) + + it('treats a same-repo review upstream that already tracks the branch as usable', () => { + // Why: a same-repo review must not stay blocked while its push target is + // unhydrated — the real upstream already targets the review head. + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature/foo', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature/foo', + ahead: 7, + behind: 2 + } + }) + ).toBe(true) + }) + + it('keeps blocking a review whose upstream tracks an unrelated fork/helper head', () => { + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature', + upstreamStatus: unrelatedUpstream + }) + ).toBe(false) + }) + + it('keeps blocking a resolvable review with no real upstream', () => { + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + ).toBe(false) + }) +}) + +describe('resolveHostedReviewActionUpstreamStatus with a same-repo upstream', () => { + it('does not synthesize hasUpstream:false when the real upstream is the review head', () => { + const realUpstream = { + hasUpstream: true, + upstreamName: 'origin/mobile-resume-suspected-fixes', + ahead: 7, + behind: 2 + } + const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: realUpstream + }) + expect(canUseHostedReviewPushTarget).toBe(true) + expect( + resolveHostedReviewActionUpstreamStatus({ + hasHostedReviewLink: true, + hasResolvableHostedReviewPushTargetLink: true, + hostedReviewState: 'open', + isHostedReviewStateLoading: false, + canUseHostedReviewPushTarget, + upstreamStatus: realUpstream + }) + ).toBe(realUpstream) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts index 9dd07b7e27b..99be32e886e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts @@ -1,11 +1,13 @@ import type { GitPushTarget, GitUpstreamStatus } from '../../../../shared/types' import type { HostedReviewState } from '../../../../shared/hosted-review' import { getPublishTargetDisplayName } from '../../../../shared/git-publish-target-status' +import { gitRefTargetsBranchName } from '../../../../shared/git-remote-branch-name' export function hasUsableHostedReviewPushTarget(args: { pushTarget?: GitPushTarget upstreamStatus?: GitUpstreamStatus hasResolvableHostedReviewPushTargetLink?: boolean + branchName?: string }): boolean { if (args.pushTarget) { return ( @@ -14,9 +16,16 @@ export function hasUsableHostedReviewPushTarget(args: { ) } if (args.hasResolvableHostedReviewPushTargetLink) { - // Why: a bare branch-config flag does not identify which review head it will - // push to; resolver-backed links need hydrated target metadata to prove it. - return false + // Why: a same-repo review's head is the checked-out branch, so a real + // upstream tracking it is safe before the resolver hydrates. Fork/cross-repo + // heads differ, so a mismatched or missing upstream stays blocked. The + // review's remote is unknown pre-hydration, so this can only match the + // branch leaf; the strict remote+branch check above takes over once known. + return ( + args.upstreamStatus?.hasUpstream === true && + args.branchName !== undefined && + gitRefTargetsBranchName(args.upstreamStatus.upstreamName, args.branchName) + ) } return args.upstreamStatus?.hasConfiguredPushTarget === true } From 5b1fcb0ff09c5db6076a3e41d08e6e192cf13bc4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:56:33 -0700 Subject: [PATCH 030/138] Fix task workspace handoff after switching workspaces (#8226) Co-authored-by: Orca --- .../src/lib/worktree-creation-flow.test.ts | 59 +++++++++++++++++++ .../src/lib/worktree-creation-flow.ts | 17 ++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 1ceebd2093f..f614c679c7e 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -504,6 +504,65 @@ describe('staged background worktree creation', () => { }) }) + it('reveals the completed workspace after the user switches to another workspace', async () => { + let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void + store.createWorktree.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve + }) + ) + + const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { + revealCreationSurface: false + }) + + expect(started).toBe(true) + await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledTimes(1)) + // Why: selecting a real workspace clears only the pending surface pointer; + // completion should still finish the task-launch handoff once it is ready. + store.activePendingCreationId = null + resolveCreate({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + await flushAsyncWorktreeCreation() + + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + sidebarRevealBehavior: 'auto' + }) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + }) + + it('does not reveal a workspace cancelled during post-create trust preflight', async () => { + let resolveTrust!: () => void + const markTrusted = vi.fn( + () => + new Promise((resolve) => { + resolveTrust = resolve + }) + ) + globalThis.window = { api: { agentTrust: { markTrusted } } } as never + store.repos = [{ id: 'repo-1', connectionId: null }] + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ agent: 'codex' }), + { revealCreationSurface: false } + ) + + expect(started).toBe(true) + await vi.waitFor(() => expect(markTrusted).toHaveBeenCalledTimes(1)) + delete store.pendingWorktreeCreations['creation-1'] + store.activePendingCreationId = null + resolveTrust() + await vi.waitFor(() => expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledTimes(1)) + + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + }) + // Why: one-click "Start workspace from issue" commonly backgrounds, so the // user-moved-on path is the common delivery for the repo's issue command; it // must thread through as the 5th positional arg, not be dropped to undefined. diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 9af1dc61ebe..6bdac108c23 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -214,13 +214,20 @@ async function executeWorktreeCreation( await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId) } - // `createWorktree` already inserted the real worktree row. Whether we steal - // the view depends on whether the user is still watching this creation. - const stillActive = isPendingCreationSurfaceVisible(creationId) + // `createWorktree` already inserted the real worktree row. Leaving for an app + // view keeps the create in the background, while selecting another workspace + // means the user still expects this task-launch handoff when it becomes ready; + // the entry guard prevents a late trust preflight from reviving a cancelled create. + const completionState = useAppStore.getState() + const shouldActivateOnCompletion = + completionState.pendingWorktreeCreations[creationId] !== undefined && + (isPendingCreationSurfaceVisible(creationId) || + (completionState.activeView === 'terminal' && + completionState.activePendingCreationId === null)) let activation: ActivateAndRevealResult | false = false let primaryTabId: string | null - if (stillActive) { + if (shouldActivateOnCompletion) { activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), @@ -254,7 +261,7 @@ async function executeWorktreeCreation( startup: preparedRequest.startupPlan }) } - if (stillActive && !preparedRequest.suppressTerminalFocusOnCompletion) { + if (shouldActivateOnCompletion && !preparedRequest.suppressTerminalFocusOnCompletion) { queueNewWorkspaceTerminalFocus(worktree.id, activation) } From 026516f32360f93cafc65843cd95da032860d34d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:04:38 -0700 Subject: [PATCH 031/138] the-sandbox-flag-is-2 (#8236) * Fix mobile source control drawer overflow and branch-compare state loss - Render BottomDrawer in a native Modal so it covers the full viewport even when mounted inside a ScrollView. - Move the conflict/Abort row onto its own line so it never overflows the branch card, and enlarge the Abort hit target. - Show the committed-on-branch footer even when the changed-files SectionList has no sections, since RN skips ListFooterComponent for empty sections. - Stop branch-compare state from collapsing to idle/error on transient base-ref resolution failures when a ready result should be preserved. * Add mobile source control drawer reload screenshot Attaches an evidence screenshot for the mobile source control drawer overflow / branch-compare state loss fix. * Remove stray temp screenshot file Accidentally committed debug artifact from mobile source control drawer work; not needed in the repo. --- mobile/src/components/BottomDrawer.tsx | 188 +++++++++--------- .../MobileSourceControlBranchCard.tsx | 37 ++-- .../MobileSourceControlContent.tsx | 52 +++-- .../mobile-source-control-styles.ts | 27 ++- .../use-mobile-source-control-loaders.ts | 21 +- .../use-mobile-source-control-state.ts | 2 + 6 files changed, 195 insertions(+), 132 deletions(-) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 0f83bbf5f54..4e24e8c2e06 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -7,7 +7,8 @@ import { useWindowDimensions, ScrollView, Keyboard, - BackHandler + BackHandler, + Modal } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' @@ -264,96 +265,105 @@ function MountedBottomDrawer({ return { opacity: progress.value * dragFade } }) + // Why: rendering through a full-screen Modal lifts the sheet into its own + // native window so it always covers the viewport — even when the drawer is + // mounted deep inside a ScrollView, where a plain absolute overlay anchors to + // the scrolled content and clips the sheet. The Modal stays mounted (visible) + // for the whole life of MountedBottomDrawer so the reanimated exit animation + // runs before the parent unmounts us; show/hide is driven by `progress`, so + // animationType stays "none". onRequestClose handles the Android back button. return ( - - - - - - - - - {!contentScrollable ? ( - <> - - - - - - {children} - - ) : dragContentToDismiss ? ( - <> - - - - - - - - - - {children} - - - - - - ) : ( - <> - - - - - - - {children} - - - )} - + + + + + - - - + + + + {!contentScrollable ? ( + <> + + + + + + {children} + + ) : dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} + + + + + + ) } diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx index 04aae01b0bc..9be5430c807 100644 --- a/mobile/src/source-control/MobileSourceControlBranchCard.tsx +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -55,23 +55,28 @@ export function MobileSourceControlBranchCard({ {unstagedCount} changed {stagedCount} staged {branchCount > 0 ? {branchCount} on branch : null} - {showConflict ? ( - - {conflictOperation} - {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={conflictBusy} - onPress={() => onAbortConflict(conflictOperation)} - > - - {mobileConflictAbortLabel(conflictOperation, conflictAborting)} - - - ) : null} - - ) : null} + {/* Own row so Abort never overflows past the card when counts are long. */} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [ + styles.abortButton, + conflictBusy && styles.abortButtonDisabled, + pressed && !conflictBusy && styles.abortPressed + ]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} {prChip ? : null} ) diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index a28504a5c53..885d8bf0c4d 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,4 +1,12 @@ -import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' +import { + ActivityIndicator, + Pressable, + ScrollView, + SectionList, + Text, + TextInput, + View +} from 'react-native' import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' @@ -13,7 +21,8 @@ type Props = { state: MobileSourceControlState } -// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. +// Changes tab: local file changes only — uncommitted (staged/unstaged) plus +// committed-on-branch vs base. PR conflicts and push status live elsewhere. export function MobileSourceControlContent({ state }: Props) { const { insets, @@ -49,6 +58,21 @@ export function MobileSourceControlContent({ state }: Props) { const shouldShowGenerateButton = stagedCount > 0 || generatingMessage const createPrHeroActive = createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst + const branchCompareFooter = ( + + ) return ( <> @@ -126,9 +150,15 @@ export function MobileSourceControlContent({ state }: Props) { {!hasVisibleChanges ? ( - No Changes + No local changes Working tree is clean. + ) : sections.length === 0 ? ( + // Why: RN SectionList with empty `sections` often skips ListFooterComponent, + // which hid "Committed on Branch" when only branch files remain. + + {branchCompareFooter} + ) : ( {section.data.length} )} - ListFooterComponent={ - - } + ListFooterComponent={branchCompareFooter} stickySectionHeadersEnabled={false} contentContainerStyle={styles.listContent} /> diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index ea766731840..d8dc20a1469 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -92,6 +92,7 @@ const baseStyles = StyleSheet.create({ }, countRow: { flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.sm }, @@ -99,29 +100,43 @@ const baseStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Separate line under counts — keeps Abort inside the card on narrow phones. conflictRow: { flexDirection: 'row', + flexWrap: 'wrap', alignItems: 'center', - gap: spacing.sm + gap: spacing.sm, + marginTop: spacing.sm, + alignSelf: 'flex-start', + maxWidth: '100%' }, conflictText: { color: colors.statusAmber, fontSize: typography.metaSize, textTransform: 'capitalize' }, + // Match bulk-action hit target so Abort reads as a real control, not a chip. abortButton: { - paddingHorizontal: spacing.sm, - paddingVertical: 2, + minHeight: 32, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, borderRadius: radii.button, borderWidth: 1, - borderColor: colors.statusAmber + borderColor: colors.statusAmber, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0 }, abortPressed: { - backgroundColor: colors.bgRaised + opacity: 0.75 + }, + abortButtonDisabled: { + opacity: 0.45 }, abortText: { color: colors.statusAmber, - fontSize: typography.metaSize, + fontSize: typography.bodySize, fontWeight: '600', textTransform: 'capitalize' }, diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.ts b/mobile/src/source-control/use-mobile-source-control-loaders.ts index 5a373ea5025..86055d21d3f 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.ts @@ -104,8 +104,18 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr return false } if (!baseRef) { - setBranchCompareState({ kind: 'idle' }) - return true + // Why: wiping a prior ready compare to idle makes Changes say "No + // Changes" even when commits still exist (e.g. after abort-merge refresh). + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { + kind: 'error', + message: 'Unable to resolve the base branch for comparison.' + } + }) + return false } const response = await client.sendRequest('git.branchCompare', { worktree: `id:${worktreeId}`, @@ -116,7 +126,12 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr } if (!response.ok) { if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { - setBranchCompareState({ kind: 'idle' }) + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'idle' } + }) return false } throw new Error(response.error?.message || 'Unable to load committed changes') diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index c9a8ca406b0..e50525f3c62 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -15,6 +15,7 @@ import { canOpenMobileBranchCompareDiff, formatMobileBranchCompareSummary } from './mobile-branch-compare' + import { buildMobileSourceControlSections, countStagedEntries, @@ -140,6 +141,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara })), [branchCompareCanOpen, branchCompareSection] ) + // Local changes only: dirty files + committed file diffs vs base (not PR/push). const shouldShowBranchCompareSection = branchEntries.length > 0 || branchCompareState.kind === 'loading' || From 66cad529a2abb66a81a51191fa57d98cb68760ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:06:56 -0700 Subject: [PATCH 032/138] Improve chinese translation 2 (#8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve zh/ja/ko/es translations for missing and untranslated i18n strin - Translates several previously English-only entries (locked workspace, host removal, subagent labels, org/profile switcher dialogs) that were left untranslated in each locale file - Fixes zh/ja/ko/es JSON EOF newline formatting * Improve zh/ja/ko/es translation quality and terminology consistency - Fix mistranslated/untranslated strings (English leftovers in es/ja, incorrect "role"→"forbidden" wording in ko) - Standardize terminology: 子代理→子智能体, 中立→中性 (zh); 하위 에이전트→서브에이전트 (ko); align count-phrase word order with natural ko/ko phrasing (e.g. "N개 X" → "X N개") --- src/renderer/src/i18n/locales/es.json | 236 +++++++++++++------------- src/renderer/src/i18n/locales/ja.json | 122 ++++++------- src/renderer/src/i18n/locales/ko.json | 106 ++++++------ src/renderer/src/i18n/locales/zh.json | 48 +++--- 4 files changed, 256 insertions(+), 256 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 36873744e79..ffffbfb77bd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -251,8 +251,8 @@ "6d3318e813": "No se pudieron importar repositorios", "3be0f7df04": "No se puede abrir la carpeta en el host seleccionado", "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.", - "2dcd706774": "Project also exists in another profile", - "presenceProfileOverflow": "{{names}} +{{count}} more" + "2dcd706774": "El proyecto también existe en otro perfil", + "presenceProfileOverflow": "{{names}} +{{count}} más" }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -270,20 +270,20 @@ }, "orca": { "profiles": { - "612f7f6861": "Failed to create profile", - "319d7cf39b": "Cloud profile created", - "d6e764e7db": "Reconnect this profile", - "f0c9e11a6d": "Failed to create cloud profile", - "8b8fa73174": "Orca Cloud sign-in is not configured", - "33290e88ed": "Failed to connect profile", - "9fcb07a796": "Profile connected", - "2f6c78a039": "Failed to refresh profile auth", - "a37b5e6d37": "Signed out of profile", - "83600521e7": "Failed to sign out", - "76deec8f58": "Failed to switch organization", - "7d4bc516ee": "Failed to switch profile", - "f518e89aa5": "Project already exists in that profile", - "f03ae7f27b": "Failed to transfer project" + "612f7f6861": "Error al crear perfil", + "319d7cf39b": "Perfil en la nube creado", + "d6e764e7db": "Reconectar este perfil", + "f0c9e11a6d": "Error al crear perfil en la nube", + "8b8fa73174": "El inicio de sesión de Orca Cloud no está configurado", + "33290e88ed": "Error al conectar perfil", + "9fcb07a796": "Perfil conectado", + "2f6c78a039": "Error al actualizar autenticación del perfil", + "a37b5e6d37": "Sesión cerrada del perfil", + "83600521e7": "Error al cerrar sesión", + "76deec8f58": "Error al cambiar organización", + "7d4bc516ee": "Error al cambiar perfil", + "f518e89aa5": "El proyecto ya existe en ese perfil", + "f03ae7f27b": "Error al transferir proyecto" } } } @@ -4309,8 +4309,8 @@ "ead7b8ee15": "Tiene archivos modificados. Usa Forzar eliminación para eliminarlo de todos modos.", "905fc8efac": "Git ya eliminó este espacio de trabajo. Usa Forzar eliminación para borrarlo de Orca.", "0899ebdb28": "Git ya olvidó este espacio de trabajo, pero su directorio todavía está en disco. Usa Forzar eliminación para eliminar el directorio huérfano.", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "Este espacio de trabajo está bloqueado por Git. Ejecute git worktree unlock desde su repositorio, luego intente la eliminación de nuevo.", + "lockedReason": "Este espacio de trabajo está bloqueado por Git. Git reportó: {{value0}}. Ejecute git worktree unlock desde su repositorio, luego intente la eliminación de nuevo." } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "Cancelar", "7a8b9c0d1e": "Abrir ajustes", "8b9c0d1e2f": "Quitar host", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "No se pudieron eliminar {{count}} espacios de trabajo de este anfitrión. El anfitrión se conservó para que pueda reintentar.", "oneWorkspace": "1 espacio de trabajo", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} espacios de trabajo", + "hostHasWorkspacesDefault": "Elimina {{value0}} y sus credenciales de esta computadora. Sus {{value1}} permanecen en Orca — los archivos remotos no se tocan.", + "alsoDeleteRemote": "También eliminar estos {{value0}} en {{value1}}", + "alsoForgetLocal": "También eliminar estos {{value0}} de Orca", + "advanced": "Avanzado", "alsoDeleteRemoteHint": "Elimina permanentemente los espacios de trabajo Git remotos y sus ramas. Esta acción no se puede deshacer.", "alsoForgetLocalHint": "Solo limpia de Orca. Los archivos, espacios de trabajo y ramas remotas permanecen intactos." }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Error de reconexión", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", - "title": "Delete “{{name}}”?", + "forgetBody": "Elimina este espacio de trabajo solo de Orca. Los archivos, el worktree de Git y las ramas en {{host}} permanecen intactos.", + "title": "¿Eliminar “{{name}}”?", "disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.", - "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "ghostBody": "{{host}} ya no es un host SSH guardado, por lo que este espacio de trabajo ya no está conectado a un host activo. Solo se puede eliminar de Orca — los archivos y ramas en el remoto permanecen intactos.", + "cancel": "Cancelar", "forget": "Eliminar de Orca", "reconnectAndDelete": "Reconectar y eliminar" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "Se terminaron {{value0}} sesiones.", "63520148e2": "La sesión {{value0}} se negó a salir.", "cc0a26cb14": "{{value0}} sesiones se negaron a salir.", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "Pestañas de terminal ausentes: {{value0}}/{{value1}}. Intentos de cierre fallidos: {{value2}}. Solicitudes de apagado PTY exactas aceptadas: {{value3}}; fallidas: {{value4}}.", "2e57c1a940": "El resultado del apagado del daemon no está verificado porque su solicitud de gestión falló.", "993af6052c": "La gestión del daemon reportó salida: {{value0}}/{{value1}}; aún presente antes de la limpieza exacta: {{value2}}.", "1f0d8ac762": "La limpieza del terminal terminó con errores.", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "Saltar al panel original", "jumpToWorktree": "Saltar al worktree", "recoverableBadge": "No guardada", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 subagente", + "subagentCountPlural": "{{value0}} subagentes" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "No hay ningún worktree activo disponible." }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", + "subagentsCount": "Subagentes ({{value0}})", "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "viewLog": "Ver registro" } } }, @@ -10483,8 +10483,8 @@ "fb5f28330e": "Mostrar en la barra lateral", "c669abcf8f": "Ocultar de la barra lateral", "e1c7b4a92d": "Configúralo en Ajustes > Móvil.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", + "a4f8c2d91e": "Eliminar Orca Mobile de la barra lateral izquierda", + "b7e3d1c84a": "Mostrar Orca Mobile en la barra lateral izquierda", "f3d8e5b71a": "Vuelve a añadir el acceso directo a la barra lateral." }, "PhoneCarousel": { @@ -12685,149 +12685,149 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "Inicio de sesión en la nube no disponible" }, - "account": "Account", - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "account": "Cuenta", + "reconnect": "Reconectar perfil", + "connect": "Conectar perfil", + "organization": "Organización", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "Crear perfil para organización" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", + "signout": "Cerrar sesión", + "16e3681072": "Nuevo perfil local", + "e3b91a3d90": "Crear un perfil vacío para proyectos y worktrees separados.", + "f322e1f4d6": "Nombre del perfil", + "cfa59f8ad1": "Crear y cambiar", "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile", + "4815f7d163": "Cambiar perfil", + "d00d853e2a": "Gestionar perfiles", + "c106c674fe": "Nuevo perfil local", "org": { - "members": "Organization members" + "members": "Miembros de la organización" } }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects.", - "projectCountSingular": "{{count}} project", - "projectCountPlural": "{{count}} projects" + "04e7bd2a23": "Transferir", + "128c7dfe64": "Copiar a", + "df8b7d876b": "Mover a", + "9aa26347b3": "Proyecto movido", + "816ce624b6": "Proyecto copiado", + "2c45bda8d3": "Gestionar perfiles", + "2db945e4a0": "Copiar o mover proyectos del perfil activo a otro perfil local.", + "8668cb2946": "No hay proyectos en este perfil.", + "93034915ab": "Cree otro perfil antes de copiar proyectos.", + "projectCountSingular": "{{count}} proyecto", + "projectCountPlural": "{{count}} proyectos" }, "project": { "transfer": { "confirm": { "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "¿Mover proyecto?", + "description": "Mover {{repoName}} a {{targetName}}. Orca lo elimina de {{activeProfileName}}, mantiene los archivos en su lugar y relanza en {{targetName}}.", + "action": "Mover proyecto" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "¿Copiar proyecto?", + "description": "Copiar {{repoName}} a {{targetName}}. Ambos perfiles apuntarán a los mismos archivos con metadatos de Orca separados.", + "action": "Copiar proyecto" }, "live": { - "work": "Live work in this project" + "work": "Trabajo activo en este proyecto" }, - "cancel": "Cancel", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" + "cancel": "Cancelar", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "¿Cerrar sesión?", + "description": "Cerrar sesión de {{profileName}} y mantener sus proyectos, worktrees y metadatos locales en este dispositivo.", + "cancel": "Cancelar", + "action": "Cerrar sesión" } }, "switch": { "confirm": { - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "el perfil seleccionado", + "title": "¿Cambiar perfiles?", + "description": "Cambiar a {{targetName}} relanzará Orca y recargará el espacio de trabajo de {{activeProfileName}}.", "live": { - "work": "Live work in this profile" + "work": "Trabajo activo en este perfil" }, - "cancel": "Cancel", - "switch": "Switch profile", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" + "cancel": "Cancelar", + "switch": "Cambiar perfil", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } }, "org": { "members": { "role": { - "label": "Role", - "owner": "Owner", - "admin": "Admin", - "member": "Member" + "label": "Rol", + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" }, "not": { "signed": { - "in": "They haven't signed in to Orca yet." + "in": "Aún no han iniciado sesión en Orca." } }, "remove": { - "confirm": "Remove", - "cancel": "Cancel", - "label": "Remove teammate" + "confirm": "Eliminar", + "cancel": "Cancelar", + "label": "Eliminar compañero" }, - "title": "Organization members", + "title": "Miembros de la organización", "load": { - "error": "Couldn't load organization members." + "error": "No se pudieron cargar los miembros de la organización." }, - "retry": "Try again", + "retry": "Intentar de nuevo", "pending": { - "title": "Pending invites", - "revoke": "Revoke" + "title": "Invitaciones pendientes", + "revoke": "Revocar" }, "invite": { - "title": "Invite", + "title": "Invitar", "placeholder": "teammate@example.com", - "role": "Invite role", - "submit": "Send" + "role": "Rol de invitación", + "submit": "Enviar" }, "error": { - "forbidden": "Your role can't do that.", - "conflict": "Already a member or invited.", + "forbidden": "Tu rol no puede hacer eso.", + "conflict": "Ya es miembro o fue invitado.", "remove": { - "self": "You can't remove yourself." + "self": "No puedes eliminarte a ti mismo." }, "own": { - "role": "You can't change your own role." + "role": "No puedes cambiar tu propio rol." }, "not": { - "found": "That teammate is no longer in this organization." + "found": "Ese compañero ya no está en esta organización." }, - "reconnect": "Reconnect your profile to manage members.", - "unconfigured": "Organization management is unavailable in this build.", - "failed": "Something went wrong. Please try again." + "reconnect": "Reconecta tu perfil para gestionar miembros.", + "unconfigured": "La gestión de organización no está disponible en esta compilación.", + "failed": "Algo salió mal. Por favor, intenta de nuevo." }, "subtitle": { - "named": "People in {{orgName}} who can collaborate on Orca.", - "default": "People in your organization who can collaborate on Orca." + "named": "Personas en {{orgName}} que pueden colaborar en Orca.", + "default": "Personas en tu organización que pueden colaborar en Orca." } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c4319b90e53..f752cfadf71 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4290,8 +4290,8 @@ "ead7b8ee15": "ファイルが変更されました。とにかく削除するには、強制削除を使用してください。", "905fc8efac": "Git はこのワークスペースをすでに削除しています。 Orca から削除するには、強制削除を使用します。", "0899ebdb28": "Git はすでにこのワークスペースを忘れていますが、そのディレクトリはまだディスク上にあります。孤立したディレクトリを削除するには、強制削除を使用します。", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "このワークスペースはGitによってロックされています。リポジトリでgit worktree unlock を実行してから、削除を再試行してください。", + "lockedReason": "このワークスペースはGitによってロックされています。Gitレポート: {{value0}}。リポジトリでgit worktree unlock を実行してから、削除を再試行してください。" } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "キャンセル", "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "ホストを削除", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "このホストの {{count}} 個のワークスペースを削除できませんでした。ホストは保持されたので、再試行できます。", "oneWorkspace": "1つのワークスペース", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} 個のワークスペース", + "hostHasWorkspacesDefault": "このコンピュータから {{value0}} とその認証情報を削除します。{{value1}} は Orca に残ります — リモートファイルは変更されません。", + "alsoDeleteRemote": "また、{{value1}} のこれらの {{value0}} も削除", + "alsoForgetLocal": "また、Orca からこれらの {{value0}} も削除", + "advanced": "詳細設定", "alsoDeleteRemoteHint": "リモートGitワークスペースとそのブランチを完全に削除します。この操作は取り消せません。", "alsoForgetLocalHint": "Orcaからのみクリアします。リモートファイル、ワークスペース、ブランチはそのまま残ります。" }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "再接続に失敗しました", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", + "forgetBody": "このワークスペースをOrcaからのみ削除します。ファイル、Gitワークツリー、{{host}}のブランチはそのまま残ります。", "title": "Delete “{{name}}”?", "disconnectedBody": "このワークスペースのSSHホストが接続されていません。再接続してリモートでも削除するか、Orcaからのみ削除してください。", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "cancel": "キャンセル", "forget": "Orcaから削除", "reconnectAndDelete": "再接続して削除" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "{{value0}} 個のセッションを終了しました。", "63520148e2": "{{value0}} 個のセッションが終了を拒否しました。", "cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "ターミナルタブがありません: {{value0}}/{{value1}}。閉じる試行失敗: {{value2}}。正確なPTYシャットダウン要求が受け入れられました: {{value3}}。失敗: {{value4}}。", "2e57c1a940": "管理リクエストが失敗したため、デーモンのシャットダウン結果が検証されていません。", "993af6052c": "デーモン管理が終了を報告:{{value0}}/{{value1}};正確なクリーンアップ前にまだ存在:{{value2}}。", "1f0d8ac762": "ターミナルクリーンアップがエラーで完了しました。", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "元のペインにジャンプ", "jumpToWorktree": "ワークツリーにジャンプ", "recoverableBadge": "未保存", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 件のサブエージェント", + "subagentCountPlural": "{{value0}} 件のサブエージェント" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "利用可能なアクティブなワークツリーはありません。" }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "サブエージェント ({{value0}})", + "messageCount": "{{value0}}件のメッセージ", + "viewLog": "ログを表示" } } }, @@ -10483,9 +10483,9 @@ "fb5f28330e": "サイドバーに表示", "c669abcf8f": "サイドバーから隠す", "e1c7b4a92d": "Configure in Settings > Mobile.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "a4f8c2d91e": "左サイドバーからOrca Mobileを削除", + "b7e3d1c84a": "左サイドバーにOrca Mobileを表示", + "f3d8e5b71a": "ショートカットを左サイドバーに再度追加します。" }, "PhoneCarousel": { "96d651cb87": "Terminal セッション", @@ -12685,7 +12685,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "クラウド サインインは利用できません" + "unavailable": "クラウドログインを利用できません" }, "reconnect": "プロファイルを再接続", "connect": "プロファイルを接続", @@ -12693,7 +12693,7 @@ "create": { "profile": { "for": { - "org": "組織用のプロファイルを作成" + "org": "組織用のプロフィールを作成" } } }, @@ -12708,7 +12708,7 @@ "c106c674fe": "新しいローカル プロファイル", "account": "アカウント", "org": { - "members": "組織のメンバー" + "members": "組織メンバー" } }, "management": { @@ -12729,105 +12729,105 @@ "confirm": { "move": { "title": "プロジェクトを移動しますか?", - "description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。", + "description": "{{repoName}}を{{targetName}}に移動します。Orcaは{{activeProfileName}}から削除し、ファイルはそのまま保持し、{{targetName}}に再起動します。", "action": "プロジェクトを移動" }, "copy": { "title": "プロジェクトをコピーしますか?", - "description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。", + "description": "{{repoName}}を{{targetName}}にコピーします。両方のプロフィールが同じファイルを別のOrcaメタデータで参照します。", "action": "プロジェクトをコピー" }, "live": { - "work": "このプロジェクトで実行中の作業" + "work": "このプロジェクトのアクティブな作業" }, "cancel": "キャンセル", - "terminalSingular": "{{count}} 個の実行中ターミナル タブ", - "terminalPlural": "{{count}} 個の実行中ターミナル タブ", - "agentSingular": "{{count}} 個のアクティブなエージェント", - "agentPlural": "{{count}} 個のアクティブなエージェント", - "browserSingular": "{{count}} 個のブラウザ ワークスペース", - "browserPlural": "{{count}} 個のブラウザ ワークスペース" + "terminalSingular": "{{count}}個のアクティブなターミナルタブ", + "terminalPlural": "{{count}}個のアクティブなターミナルタブ", + "agentSingular": "{{count}}個のアクティブなエージェント", + "agentPlural": "{{count}}個のアクティブなエージェント", + "browserSingular": "{{count}}個のブラウザワークスペース", + "browserPlural": "{{count}}個のブラウザワークスペース" } } }, "signout": { "confirm": { - "title": "サインアウトしますか?", - "description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。", + "title": "ログアウトしますか?", + "description": "{{profileName}}からログアウトし、プロジェクト、ワークツリー、ローカルメタデータをこのデバイスに保持します。", "cancel": "キャンセル", - "action": "サインアウト" + "action": "ログアウト" } }, "switch": { "confirm": { - "target": "選択したプロファイル", - "title": "プロファイルを切り替えますか?", - "description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。", + "target": "選択されたプロフィール", + "title": "プロフィールを切り替えますか?", + "description": "{{targetName}}に切り替えると、Orcaが再起動され、{{activeProfileName}}のワークスペースが再読み込みされます。", "live": { - "work": "このプロファイルで実行中の作業" + "work": "このプロフィールのアクティブな作業" }, "cancel": "キャンセル", - "switch": "プロファイルを切り替え", - "terminalSingular": "{{count}} 個の実行中ターミナル タブ", - "terminalPlural": "{{count}} 個の実行中ターミナル タブ", - "agentSingular": "{{count}} 個のアクティブなエージェント", - "agentPlural": "{{count}} 個のアクティブなエージェント", - "browserSingular": "{{count}} 個のブラウザ ワークスペース", - "browserPlural": "{{count}} 個のブラウザ ワークスペース" + "switch": "プロフィールを切替", + "terminalSingular": "{{count}}個のアクティブなターミナルタブ", + "terminalPlural": "{{count}}個のアクティブなターミナルタブ", + "agentSingular": "{{count}}個のアクティブなエージェント", + "agentPlural": "{{count}}個のアクティブなエージェント", + "browserSingular": "{{count}}個のブラウザワークスペース", + "browserPlural": "{{count}}個のブラウザワークスペース" } }, "org": { "members": { "role": { - "label": "ロール", + "label": "役割", "owner": "オーナー", "admin": "管理者", "member": "メンバー" }, "not": { "signed": { - "in": "まだ Orca にサインインしていません。" + "in": "まだOrcaにサインインしていません。" } }, "remove": { "confirm": "削除", "cancel": "キャンセル", - "label": "メンバーを削除" + "label": "チームメンバーを削除" }, - "title": "組織のメンバー", + "title": "組織メンバー", "load": { - "error": "組織のメンバーを読み込めませんでした。" + "error": "組織メンバーを読み込めませんでした。" }, "retry": "再試行", "pending": { "title": "保留中の招待", - "revoke": "取り消す" + "revoke": "取り消し" }, "invite": { "title": "招待", "placeholder": "teammate@example.com", - "role": "招待するロール", + "role": "招待役割", "submit": "送信" }, "error": { - "forbidden": "あなたのロールではこの操作を実行できません。", - "conflict": "すでにメンバーまたは招待済みです。", + "forbidden": "この役割では実行できません。", + "conflict": "既にメンバーまたは招待済みです。", "remove": { "self": "自分自身を削除することはできません。" }, "own": { - "role": "自分のロールは変更できません。" + "role": "自分の役割を変更することはできません。" }, "not": { - "found": "そのメンバーはこの組織に存在しません。" + "found": "そのチームメンバーはこの組織にいません。" }, - "reconnect": "メンバーを管理するにはプロファイルを再接続してください。", - "unconfigured": "このビルドでは組織の管理を利用できません。", + "reconnect": "メンバーを管理するには、プロフィールを再接続してください。", + "unconfigured": "このビルドでは組織管理が利用できません。", "failed": "問題が発生しました。もう一度お試しください。" }, "subtitle": { - "named": "{{orgName}} で Orca を共同利用できるメンバーです。", - "default": "組織内で Orca を共同利用できるメンバーです。" + "named": "{{orgName}}でOrcaに協力できる人々。", + "default": "組織でOrcaに協力できる人々。" } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d44092ab6a5..d9c32e81f71 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4290,8 +4290,8 @@ "ead7b8ee15": "파일이 변경되었습니다. 어쨌든 삭제하려면 강제 삭제를 사용하세요.", "905fc8efac": "Git은 이미 이 워크스페이스를 제거했습니다. 강제 삭제를 사용하여 Orca에서 삭제합니다.", "0899ebdb28": "Git은 이미 이 워크스페이스를 잊어버렸지만 해당 디렉토리는 여전히 디스크에 있습니다. 강제 삭제를 사용하여 분리된 디렉터리를 제거합니다.", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "이 워크트리는 Git에 의해 잠겨 있습니다. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요.", + "lockedReason": "이 워크트리는 Git에 의해 잠겨 있습니다. Git 보고: {{value0}}. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요." } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "취소", "7a8b9c0d1e": "설정 열기", "8b9c0d1e2f": "호스트 제거", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "이 호스트에서 워크트리 {{count}}개를 제거할 수 없었습니다. 재시도할 수 있도록 호스트가 유지되었습니다.", "oneWorkspace": "워크트리 1개", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "워크트리 {{count}}개", + "hostHasWorkspacesDefault": "이 컴퓨터에서 {{value0}}과(와) 해당 자격 증명을 제거합니다. {{value1}}은(는) Orca에 남아 있습니다 — 원격 파일은 변경되지 않습니다.", + "alsoDeleteRemote": "{{value1}}에서 이러한 {{value0}}도 삭제", + "alsoForgetLocal": "Orca에서 이러한 {{value0}}도 제거", + "advanced": "고급", "alsoDeleteRemoteHint": "원격 Git 워크트리와 해당 브랜치를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "alsoForgetLocalHint": "Orca에서만 제거합니다. 원격 파일, 워크트리, 브랜치는 그대로 남아 있습니다." }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "재연결 실패", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", + "forgetBody": "이 작업 공간을 Orca에서만 제거합니다. 파일, Git 워크트리, {{host}}의 브랜치는 그대로 유지됩니다.", "title": "Delete “{{name}}”?", "disconnectedBody": "이 워크트리의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "cancel": "취소", "forget": "Orca에서 제거", "reconnectAndDelete": "재연결 후 삭제" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "{{value0}}개 세션을 종료했습니다.", "63520148e2": "{{value0}}개 세션이 종료를 거부했습니다.", "cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다.", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "터미널 탭 누락: {{value0}}/{{value1}}. 닫기 시도 실패: {{value2}}. 정확한 PTY 종료 요청 수락: {{value3}}; 실패: {{value4}}.", "2e57c1a940": "관리 요청이 실패하여 데몬 종료 결과가 확인되지 않았습니다.", "993af6052c": "데몬 관리가 종료를 보고함: {{value0}}/{{value1}}; 정확한 정리 전에 아직 존재: {{value2}}.", "1f0d8ac762": "터미널 정리가 오류와 함께 완료되었습니다.", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "원래 창으로 이동", "jumpToWorktree": "작업 트리로 이동", "recoverableBadge": "저장 안 됨", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "서브에이전트 1개", + "subagentCountPlural": "서브에이전트 {{value0}}개" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다." }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "서브에이전트 ({{value0}})", + "messageCount": "{{value0}}개 메시지", + "viewLog": "로그 보기" } } }, @@ -10483,9 +10483,9 @@ "fb5f28330e": "사이드바에 표시", "c669abcf8f": "사이드바에서 숨기기", "e1c7b4a92d": "Configure in Settings > Mobile.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "a4f8c2d91e": "왼쪽 사이드바에서 Orca Mobile 제거", + "b7e3d1c84a": "왼쪽 사이드바에 Orca Mobile 표시", + "f3d8e5b71a": "단축키를 왼쪽 사이드바에 다시 추가합니다." }, "PhoneCarousel": { "96d651cb87": "Terminal 세션", @@ -12685,7 +12685,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "클라우드 로그인을 사용할 수 없습니다" + "unavailable": "클라우드 로그인 사용 불가" }, "reconnect": "프로필 다시 연결", "connect": "프로필 연결", @@ -12693,7 +12693,7 @@ "create": { "profile": { "for": { - "org": "조직용 프로필 만들기" + "org": "조직용 프로필 생성" } } }, @@ -12729,51 +12729,51 @@ "confirm": { "move": { "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", "action": "프로젝트 이동" }, "copy": { "title": "프로젝트를 복사하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필 모두 동일한 파일을 별도의 Orca 메타데이터와 함께 가리킵니다.", "action": "프로젝트 복사" }, "live": { - "work": "이 프로젝트에서 진행 중인 작업" + "work": "이 프로젝트의 활성 작업" }, "cancel": "취소", - "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", - "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", - "agentSingular": "{{count}}개의 활성 에이전트", - "agentPlural": "{{count}}개의 활성 에이전트", - "browserSingular": "{{count}}개의 브라우저 워크스페이스", - "browserPlural": "{{count}}개의 브라우저 워크스페이스" + "terminalSingular": "활성 터미널 탭 {{count}}개", + "terminalPlural": "활성 터미널 탭 {{count}}개", + "agentSingular": "활성 에이전트 {{count}}개", + "agentPlural": "활성 에이전트 {{count}}개", + "browserSingular": "브라우저 작업 공간 {{count}}개", + "browserPlural": "브라우저 작업 공간 {{count}}개" } } }, "signout": { "confirm": { "title": "로그아웃하시겠습니까?", - "description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.", + "description": "{{profileName}}에서 로그아웃하고 프로젝트, 워크트리 및 로컬 메타데이터를 이 장치에 유지합니다.", "cancel": "취소", "action": "로그아웃" } }, "switch": { "confirm": { - "target": "선택한 프로필", + "target": "선택된 프로필", "title": "프로필을 전환하시겠습니까?", - "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.", + "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 작업 공간이 다시 로드됩니다.", "live": { - "work": "이 프로필에서 진행 중인 작업" + "work": "이 프로필의 활성 작업" }, "cancel": "취소", "switch": "프로필 전환", - "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", - "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", - "agentSingular": "{{count}}개의 활성 에이전트", - "agentPlural": "{{count}}개의 활성 에이전트", - "browserSingular": "{{count}}개의 브라우저 워크스페이스", - "browserPlural": "{{count}}개의 브라우저 워크스페이스" + "terminalSingular": "활성 터미널 탭 {{count}}개", + "terminalPlural": "활성 터미널 탭 {{count}}개", + "agentSingular": "활성 에이전트 {{count}}개", + "agentPlural": "활성 에이전트 {{count}}개", + "browserSingular": "브라우저 작업 공간 {{count}}개", + "browserPlural": "브라우저 작업 공간 {{count}}개" } }, "org": { @@ -12790,18 +12790,18 @@ } }, "remove": { - "confirm": "삭제", + "confirm": "제거", "cancel": "취소", - "label": "구성원 삭제" + "label": "팀원 제거" }, "title": "조직 구성원", "load": { - "error": "조직 구성원을 불러오지 못했습니다." + "error": "조직 구성원을 로드할 수 없습니다." }, "retry": "다시 시도", "pending": { "title": "대기 중인 초대", - "revoke": "철회" + "revoke": "취소" }, "invite": { "title": "초대", @@ -12810,24 +12810,24 @@ "submit": "보내기" }, "error": { - "forbidden": "현재 역할로는 이 작업을 수행할 수 없습니다.", + "forbidden": "이 작업을 수행할 권한이 없습니다.", "conflict": "이미 구성원이거나 초대되었습니다.", "remove": { - "self": "자신은 삭제할 수 없습니다." + "self": "자신을 제거할 수 없습니다." }, "own": { - "role": "자신의 역할은 변경할 수 없습니다." + "role": "자신의 역할을 변경할 수 없습니다." }, "not": { - "found": "해당 구성원이 더 이상 이 조직에 없습니다." + "found": "해당 팀원은 이 조직에 없습니다." }, "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", - "unconfigured": "이 빌드에서는 조직 관리를 사용할 수 없습니다.", - "failed": "문제가 발생했습니다. 다시 시도하세요." + "unconfigured": "이 빌드에서 조직 관리를 사용할 수 없습니다.", + "failed": "문제가 발생했습니다. 다시 시도해 주세요." }, "subtitle": { - "named": "{{orgName}}에서 Orca로 협업할 수 있는 사용자입니다.", - "default": "조직에서 Orca로 협업할 수 있는 사용자입니다." + "named": "{{orgName}}에서 Orca에 협업할 수 있는 사람들.", + "default": "조직에서 Orca에 협업할 수 있는 사람들." } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7110ab9ad65..688b5341f3d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3803,7 +3803,7 @@ "8c097ef04e": "来自 Orca。它仍保留在您的磁盘上。", "e62415c3d0": "这仅删除", "b79b39d865": "删除项目", - "fromOrcaSsh": "from Orca. Its files stay on {{value0}} — re-add that SSH host to recover it." + "fromOrcaSsh": "从 Orca 中移除。其文件保留在 {{value0}} 上 — 重新添加该 SSH 主机即可恢复。" }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "显示活动工作区" @@ -4085,7 +4085,7 @@ "3e65e11cc6": "工作区元数据", "c7fa72ead0": "编辑笔记", "93cbea12c2": "笔记", - "eace1d2cf6": "Neutral", + "eace1d2cf6": "中性", "dbe2d18972": "更多 {{value0}} 操作", "ae76907ca6": "取消链接 {{value0}}", "ad25c3ff05": "在 {{value0}} 上查看", @@ -4191,7 +4191,7 @@ "045a8aed48": "孩子们", "2ca6e29a3c": "仓库", "bb85cd86ba": "为 {{value0}} 创建工作区", - "ebadb7eadb": "{{value0}} {{value1}} child {{value2}}", + "ebadb7eadb": "{{value0}} {{value1}} 子项 {{value2}}", "20bebf9c7f": "显示 {{value0}} 个子工作区", "c1f4a31623": "显示 {{value0}} 个子工作区", "e97297cb75": "隐藏 {{value0}} 个子工作区", @@ -4290,8 +4290,8 @@ "ead7b8ee15": "它已更改文件。无论如何,请使用强制删除将其删除。", "905fc8efac": "Git 已经删除了这个工作区。使用强制删除将其从 Orca 中清除。", "0899ebdb28": "Git 已经忘记了这个工作区,但它的目录仍然在磁盘上。使用强制删除来删除孤立目录。", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "此工作区被 Git 锁定。请在其仓库中运行 git worktree unlock ,然后重试删除。", + "lockedReason": "此工作区被 Git 锁定。Git 报告:{{value0}}。请在其仓库中运行 git worktree unlock ,然后重试删除。" } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "取消", "7a8b9c0d1e": "打开设置", "8b9c0d1e2f": "删除主机", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "无法移除此主机的 {{count}} 个工作区。主机已保留,您可以重试。", "oneWorkspace": "1 个工作区", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} 个工作区", + "hostHasWorkspacesDefault": "从此计算机移除 {{value0}} 及其凭据。其 {{value1}} 保留在 Orca 中 — 远程文件不会被删除。", + "alsoDeleteRemote": "同时在 {{value1}} 上删除这些 {{value0}}", + "alsoForgetLocal": "同时从 Orca 中移除这些 {{value0}}", + "advanced": "高级", "alsoDeleteRemoteHint": "永久删除远程 Git worktree 及其分支。此操作不可撤销。", "alsoForgetLocalHint": "仅从 Orca 中清除。远程文件、worktree 和分支不受影响。" }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "重新连接失败", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", - "title": "Delete “{{name}}”?", + "forgetBody": "仅从 Orca 中移除此工作区。文件、Git 工作树和 {{host}} 上的分支保持不变。", + "title": "从 Orca 中移除“{{name}}”?", "disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。", - "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "ghostBody": "{{host}} 不再是已保存的 SSH 主机,因此此工作区不再连接到活跃主机。只能从 Orca 中移除 — 远程文件和分支保持不变。", + "cancel": "取消", "forget": "从 Orca 中移除", "reconnectAndDelete": "重新连接并删除" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "已终止 {{value0}} 个会话。", "63520148e2": "{{value0}} 个会话拒绝退出。", "cc0a26cb14": "{{value0}} 个会话拒绝退出。", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "终端标签页缺失:{{value0}}/{{value1}}。关闭尝试失败:{{value2}}。精确 PTY 关闭请求已接受:{{value3}};失败:{{value4}}。", "2e57c1a940": "守护进程关闭结果未经验证,因为其管理请求失败。", "993af6052c": "守护进程管理报告已退出:{{value0}}/{{value1}};在精确清理前仍存在:{{value2}}。", "1f0d8ac762": "终端清理完成,但有错误。", @@ -9532,7 +9532,7 @@ "bar": { "buttons": { "1fd284e931": "更多侧边栏选项卡", - "f1132ea95d": "Neutral" + "f1132ea95d": "中性" } } }, @@ -9997,7 +9997,7 @@ "recoverableEmptyOpenLogHint": "打开日志以恢复它们。", "emptyConversationDetail": "此会话没有已保存的对话,无法恢复。", "queuedMessages": "{{value0}} 条排队消息", - "subagentTranscripts": "{{value0}} 个子代理记录" + "subagentTranscripts": "{{value0}} 个子智能体记录" }, "AiVaultSessionRow": { "resumeAgentSession": "恢复 {{value0}} 会话", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "跳转到原始窗格", "jumpToWorktree": "跳转到工作树", "recoverableBadge": "未保存", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 个子智能体", + "subagentCountPlural": "{{value0}} 个子智能体" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "没有可用的活动工作树。" }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "子智能体({{value0}})", + "messageCount": "{{value0}} 条消息", + "viewLog": "查看日志" } } }, @@ -10485,7 +10485,7 @@ "e1c7b4a92d": "在设置 > 移动端中配置。", "a4f8c2d91e": "从左侧边栏隐藏 Orca 手机端", "b7e3d1c84a": "在左侧边栏显示 Orca 手机端", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "f3d8e5b71a": "将快捷方式添加回左侧边栏。" }, "PhoneCarousel": { "96d651cb87": "终端会话", From b677b2a209e66ee3693c7c9e11a67acf7616b6c8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 02:16:13 -0400 Subject: [PATCH 033/138] fix(github): recover oversized issue creation (#8217) * fix(github): recover oversized issue creation (#7704) * fix(github): preserve partial issue creation --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/github/issues.test.ts | 157 ++++++++++++++++++ src/main/github/issues.ts | 91 +++++++--- src/preload/api-types.ts | 3 +- src/preload/index.ts | 4 +- src/renderer/src/components/TaskPage.tsx | 61 ++++--- .../task-page-github-issue-creation.test.ts | 33 ++++ src/shared/types.ts | 4 + 7 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-issue-creation.test.ts diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index f68726b395a..0f65fc1572c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -239,6 +239,163 @@ describe('issue source operations', () => { ) }) + it('recovers issue 7704 oversized inline-image creation', async () => { + const imagePrefix = 'data:image/png;base64,' + const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length) + expect(body).toContain('data:image') + expect(body).toHaveLength(133596) + + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 422: body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 926, + html_url: 'https://github.com/stablyai/orca/issues/926' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue('/repo-root', 'Image issue', body, undefined, undefined, { + labels: ['bug'], + assignees: ['octo'] + }) + ).resolves.toEqual({ + ok: true, + number: 926, + url: 'https://github.com/stablyai/orca/issues/926' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + `body=${body}`, + 'title=Image issue', + 'labels[]=bug', + 'assignees[]=octo' + ]), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], + { cwd: '/repo-root' } + ) + }) + + it('recognizes the oversized-body response from structured gh stderr', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: gh'), { + stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 929, + html_url: 'https://github.com/stablyai/orca/issues/929' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 929, + url: 'https://github.com/stablyai/orca/issues/929' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('does not retry unrelated create failures', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid')) + + await expect(createIssue('/repo-root', 'Invalid issue', 'Body')).resolves.toEqual({ + ok: false, + error: 'HTTP 422: assignees is invalid' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('stops when placeholder create fails during oversized-body recovery', async () => { + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockRejectedValueOnce(new Error('HTTP 500: create failed')) + + await expect(createIssue('/repo-root', 'Placeholder failure', body)).resolves.toEqual({ + ok: false, + error: 'HTTP 500: create failed' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('preserves fields during oversized-body recovery', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue( + '/repo-root', + 'Fields issue', + body, + undefined, + null, + { + labels: ['bug'], + assignees: ['octo'] + }, + localGitOptions + ) + ).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' }) + const firstCreateArgs = [...ghExecFileAsyncMock.mock.calls[0][0]] + const fallbackCreateArgs = [...ghExecFileAsyncMock.mock.calls[1][0]] + firstCreateArgs[firstCreateArgs.indexOf(`body=${body}`)] = 'body=' + expect(fallbackCreateArgs).toEqual(firstCreateArgs) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + null, + localGitOptions + ) + }) + + it('reports partial success when oversized-body patch fails', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 928, + html_url: 'https://github.com/stablyai/orca/issues/928' + }) + }) + .mockRejectedValueOnce(new Error('HTTP 500: update failed')) + + await expect(createIssue('/repo-root', 'Partial issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 928, + url: 'https://github.com/stablyai/orca/issues/928', + bodySaveWarning: + 'Issue https://github.com/stablyai/orca/issues/928 was created, but saving its body failed: HTTP 500: update failed' + }) + }) + it('updates issue body through the REST issue endpoint', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 8f80fc6451f..b0f3fa6cb89 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -6,6 +6,7 @@ import type { ClassifiedError, GitHubAssignableUser, GitHubCreateIssueFields, + GitHubCreateIssueResult, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, @@ -15,7 +16,7 @@ import type { import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -31,6 +32,11 @@ export type IssueListResult = { error?: ClassifiedError } +function githubIssueErrorMessage(error: unknown): string { + const { stderr, stdout } = extractExecError(error) + return stderr.trim() || stdout.trim() +} + /** * Get a single issue by number. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. @@ -162,7 +168,7 @@ export async function createIssue( connectionId?: string | null, fields?: GitHubCreateIssueFields, localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { +): Promise { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } @@ -180,25 +186,71 @@ export async function createIssue( } await acquire() try { - const args = [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, - '--raw-field', - `title=${trimmedTitle}`, - '--raw-field', - `body=${body}` - ] - for (const label of fields?.labels ?? []) { - args.push('--raw-field', `labels[]=${label}`) + const createArgs = (issueBody: string) => { + const args = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, + '--raw-field', + `title=${trimmedTitle}`, + '--raw-field', + `body=${issueBody}` + ] + for (const label of fields?.labels ?? []) { + args.push('--raw-field', `labels[]=${label}`) + } + for (const assignee of fields?.assignees ?? []) { + args.push('--raw-field', `assignees[]=${assignee}`) + } + return args } - for (const assignee of fields?.assignees ?? []) { - args.push('--raw-field', `assignees[]=${assignee}`) + + const parseIssue = (stdout: string) => + JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } + + let data: { number?: number; html_url?: string; url?: string } + try { + const { stdout } = await ghExecFileAsync(createArgs(body), ghOptions) + data = parseIssue(stdout) + } catch (err) { + const message = githubIssueErrorMessage(err) + if (!/body is too long \(maximum is \d+ characters\)/i.test(message)) { + return { ok: false, error: message } + } + + // Why: GitHub rejects oversized bodies on create but accepts the same body + // on update, so establish the issue before attaching its body. + const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions) + data = parseIssue(stdout) + if (typeof data.number !== 'number') { + return { ok: false, error: 'Unexpected response from GitHub' } + } + + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${data.number}`, + '--raw-field', + `body=${body}` + ], + ghOptions + ) + } catch (patchErr) { + const patchMessage = githubIssueErrorMessage(patchErr) + const identity = data.html_url ?? data.url ?? `#${data.number}` + return { + ok: true, + number: data.number, + url: String(data.html_url ?? data.url ?? ''), + bodySaveWarning: `Issue ${identity} was created, but saving its body failed: ${patchMessage}` + } + } } - const { stdout } = await ghExecFileAsync(args, ghOptions) - const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } } @@ -208,8 +260,7 @@ export async function createIssue( url: String(data.html_url ?? data.url ?? '') } } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } + return { ok: false, error: githubIssueErrorMessage(err) } } finally { release() } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7731355972c..7c9f989be5a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -89,6 +89,7 @@ import type { GitStatusResult, GitUpstreamStatus, GitHubAssignableUser, + GitHubCreateIssueResult, GitHubPRFile, GitHubPRFileContents, GitHubPrStartPoint, @@ -1488,7 +1489,7 @@ export type PreloadApi = { body: string labels?: string[] assignees?: string[] - }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + }) => Promise countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise listWorkItems: (args: { repoPath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 4ad832fa89a..059e2aa4e90 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import type { GitHubPRRefreshReason, GitHubAssignableUser, GitHubCommentResult, + GitHubCreateIssueResult, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -1262,8 +1263,7 @@ const api = { body: string labels?: string[] assignees?: string[] - }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:createIssue', args), + }): Promise => ipcRenderer.invoke('gh:createIssue', args), countWorkItems: (args: { repoPath: string diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8d37800b8d9..c42be0d7bac 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -6865,7 +6865,9 @@ export default function TaskPage(): React.JSX.Element { labels: newIssueLabels, assignees: newIssueAssignees.map((assignee) => assignee.login) }, - { timeoutMs: 30_000 } + // Why: oversized-body recovery can require two 30-second writes + // after GitHub rejects the initial create request. + { timeoutMs: 65_000 } ) : await window.api.gh.createIssue({ repoPath: newIssueTargetRepo.path, @@ -6883,28 +6885,42 @@ export default function TaskPage(): React.JSX.Element { ) return } - toast.success( - translate('auto.components.TaskPage.3f9604efc7', 'Opened issue #{{value0}}', { - value0: result.number - }), - { - action: result.url - ? { - label: translate('auto.components.TaskPage.9c57663908', 'View'), - onClick: () => window.open(result.url, '_blank') - } - : undefined - } + const createdIssueToast = translate( + 'auto.components.TaskPage.3f9604efc7', + 'Opened issue #{{value0}}', + { value0: result.number } ) + const createdIssueToastOptions = { + action: result.url + ? { + label: translate('auto.components.TaskPage.9c57663908', 'View'), + onClick: () => window.open(result.url, '_blank') + } + : undefined + } + if (result.bodySaveWarning) { + toast.warning(createdIssueToast, { + ...createdIssueToastOptions, + description: result.bodySaveWarning + }) + } else { + toast.success(createdIssueToast, createdIssueToastOptions) + } setNewIssueOpen(false) - setNewIssueTitle('') - setNewIssueBody('') - setNewIssueLabels([]) - setNewIssueAssignees([]) - // Why: a successful submit is the only path that discards the recovery - // draft. Closing `newIssueOpen` in the same commit keeps the write-through - // effect (gated on it) from re-persisting the emptied fields. - clearNewIssueDraft() + if (result.bodySaveWarning) { + // Why: retain the unsaved body for recovery, but clear the title so + // reopening the composer cannot repeat the create with one click. + setNewIssueTitle('') + setNewIssueDraft({ title: '' }) + } else { + setNewIssueTitle('') + setNewIssueBody('') + setNewIssueLabels([]) + setNewIssueAssignees([]) + // Why: only a complete success discards the recovery draft; a partial + // body save keeps the original text available without another create. + clearNewIssueDraft() + } // Why: bump the nonce so the list refetches and shows the new issue. setTaskRefreshNonce((current) => current + 1) @@ -6973,7 +6989,8 @@ export default function TaskPage(): React.JSX.Element { newIssueTitle, openGitHubDetailPage, setDialogWorkItem, - clearNewIssueDraft + clearNewIssueDraft, + setNewIssueDraft ]) const handleCreateNewLinearProject = useCallback(async (): Promise => { diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts new file mode 100644 index 00000000000..58335c3ce7f --- /dev/null +++ b/src/renderer/src/components/task-page-github-issue-creation.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const taskPageSource = readFileSync(new URL('./TaskPage.tsx', import.meta.url), 'utf8') + +function issueCreationSection(): string { + const start = taskPageSource.indexOf('const handleCreateNewIssue') + const end = taskPageSource.indexOf('const handleCreateNewLinearProject', start) + return taskPageSource.slice(start, end) +} + +describe('TaskPage GitHub issue creation', () => { + it('covers the complete remote oversized-body recovery timeout envelope', () => { + const section = issueCreationSection() + + expect(section).toContain("'github.createIssue'") + expect(section).toContain('{ timeoutMs: 65_000 }') + }) + + it('treats a body-save warning as created while preserving the recovery draft', () => { + const section = issueCreationSection() + const warningBranch = section.slice( + section.indexOf('if (result.bodySaveWarning)'), + section.indexOf('// Why: bump the nonce') + ) + + expect(warningBranch).toContain('toast.warning') + expect(warningBranch).toContain('description: result.bodySaveWarning') + expect(warningBranch).toContain("setNewIssueDraft({ title: '' })") + expect(warningBranch).toContain('} else {') + expect(warningBranch).toContain('clearNewIssueDraft()') + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 6079fa4fe9b..24308e9327f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1743,6 +1743,10 @@ export type GitHubCreateIssueFields = { assignees?: string[] } +export type GitHubCreateIssueResult = + | { ok: true; number: number; url: string; bodySaveWarning?: string } + | { ok: false; error: string } + export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' export type GitHubIssueUpdate = { From 931e402e93f864581707db54891afdac3613b3f9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:25:46 -0700 Subject: [PATCH 034/138] Fix tui-idle detection for cursor-agent lanes with dismissed trust dialog (#8220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix tui-idle detection for Cursor Agent lanes with dismissed trust dialo Cursor Agent's persistent TUI never emits an idle OSC title, so a dismissed trust dialog stayed in scrollback and kept the wait blocked even once Cursor reached its idle "→" prompt. Detect Cursor's active prompt (busy or idle) to clear a stale trust hit, and detect its idle state specifically (prompt present, no braille spinner) to satisfy tui-idle, so busy lanes keep polling instead of returning a stale blocked result. * Add test coverage for Cursor Agent trust-dialog banner detection Verify lastIndexOf picks the later ready banner over an earlier "Cursor Agent" mention in the trust dialog body, for both the idle-resolution and busy-detection cases. * chore(i18n): sync localization catalog for aiVaultSessionLogOpen keys Pre-existing gap from #8205 (ai-vault view log) that fails `pnpm lint`'s localization-catalog check on origin/main; unrelated to the tui-idle fix but committed here to unblock CI lint on this branch. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 92 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 52 +++++++++++++-- src/renderer/src/i18n/locales/en.json | 5 ++ src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 5 ++ 7 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2b515e230bb..7a9e927cbfa 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -874,6 +874,32 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High ].join('\n') } +// Why: verbatim shape of a real cursor-agent 2026.07 idle screen. The fresh +// input prompt is "→ Plan, search, build anything" (not "→ Add a follow-up", +// which only appears after the first turn), so the matcher keys on the "→" +// glyph, not a specific placeholder. +function cursorReadyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + 'Tip: Use /plan to plan execution and reach the right outcome faster.', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + +function cursorBusyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + '⠰⠳ Thinking 28.61k tokens', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + // Why: these runtime feature tests only need message-queue semantics; using // SQLite here makes them fail on unrelated native runtime ABI drift. class InMemoryOrchestrationMessages { @@ -10525,6 +10551,72 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle for an idle Cursor lane past its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + // Cursor's dismissed trust dialog stays in the scrollback tail; the idle + // ready prompt after it must both clear the stale trust hit and satisfy idle. + runtime.onPtyData( + 'pty-bg', + [ + // Trust dialog body can mention "Cursor Agent" before the ready banner; + // lastIndexOf must pick the later banner, not this earlier hit. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorReadyScreen() + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running' + }) + }) + + it('does not block a mid-run Cursor lane on its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + // Same earlier "Cursor Agent" hit as the idle case — banner must win. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorBusyScreen() + ].join(''), + Date.now() + ) + + // Busy Cursor is neither blocked nor idle: the wait must keep polling, then + // time out honestly rather than return a stale trust block. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + ).rejects.toThrow('timeout') + }) + it('returns a blocked wait result for Codex cwd selection prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0ed4215aa8f..75beac99452 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25558,20 +25558,64 @@ function findActionableTerminalWaitBlockedSignal( if (blockedSignal === null) { return null } - const readyIndex = findKnownReadyPromptIndex(normalized) + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) // Why: retained terminal tails can include stale startup modals. If a known - // ready prompt appears after that modal, the latest signal is ready. - return readyIndex !== null && readyIndex > blockedSignal.index ? null : blockedSignal + // agent's live prompt appears after that modal, the modal was dismissed and + // the signal is no longer actionable — even if the agent is still mid-run + // (Cursor never reports idle via OSC title, so its busy prompt clears too). + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +// Why: a recognized agent's live prompt (idle OR busy) proves its startup modal +// was dismissed. Broader than the idle-only ready set so a mid-run Cursor lane +// stops reporting a stale trust hit for the rest of the session. +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null } function findKnownReadyPromptIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized) + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } +// Why: cursor-agent keeps a persistent TUI — a printed "Cursor Agent" banner and +// a "→" input-prompt line appear once its trust dialog is dismissed, in both +// busy and idle states. The banner is matched by its last occurrence so the +// trust dialog's own "Cursor Agent" body text (which precedes the banner) does +// not win. The "→" glyph is cursor-agent's input prompt marker ("→ Plan, +// search, build anything" fresh, "→ Add a follow-up" after the first turn). +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +// Why: cursor-agent never emits an idle OSC title (its bare title is dropped), +// so tui-idle can only resolve from the tail. Busy frames draw a braille +// spinner in the on-screen status line; its absence past the banner is idle. +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index eb1b76a447a..0a60a2d35c9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10086,6 +10086,11 @@ "subagentsCount": "Subagents ({{value0}})", "messageCount": "{{value0}} msgs", "viewLog": "View Log" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ffffbfb77bd..b8c369477dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10086,6 +10086,11 @@ "subagentsCount": "Subagentes ({{value0}})", "messageCount": "{{value0}} msgs", "viewLog": "Ver registro" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f752cfadf71..2796e629344 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10086,6 +10086,11 @@ "subagentsCount": "サブエージェント ({{value0}})", "messageCount": "{{value0}}件のメッセージ", "viewLog": "ログを表示" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9c32e81f71..9cd90833df5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10086,6 +10086,11 @@ "subagentsCount": "서브에이전트 ({{value0}})", "messageCount": "{{value0}}개 메시지", "viewLog": "로그 보기" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 688b5341f3d..ee6099601e6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10086,6 +10086,11 @@ "subagentsCount": "子智能体({{value0}})", "messageCount": "{{value0}} 条消息", "viewLog": "查看日志" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, From 2e4849527300547f1febe62dfef7ef70101bad92 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:31:56 -0700 Subject: [PATCH 035/138] Prevent mobile screen locking during voice dictation. (#7746) * Prevent mobile screen locking during voice dictation Integrate expo-keep-awake to prevent the mobile device from locking or sleeping while a voice dictation session is active. - Modularize useMobileDictation logic into separate helper files for keep-awake, audio chunking, session state, and desktop startup. - Acquire keep-awake lock only after successfully establishing a desktop session to avoid locking on stale start attempts. - Release the keep-awake lock on all completion, cancellation, error, and unmount paths. - Add source invariant unit tests to verify keep-awake ownership and strict cleanup ordering. * serialize keep-awake operations and avoid stale dictation start races - Implement a global execution queue and tag tracking for keep-awake operations to prevent concurrent races and stale deactivations. - Track failed native deactivations and retry them when a replacement hook owner mounts or starts a new dictation session. - Ensure stale or canceled desktop dictation starts do not reset the UI state or propagate outdated start/keep-awake failures. - Reuse the audio chunk queue wiring in useMobileDictation to avoid allocating new closure objects on the high-frequency microphone path. - Add comprehensive unit tests for the keep-awake and desktop start hooks. * Commit native recording during dictation session startup Commit native recording in the same continuation as the final session stale check. This prevents a queued cancellation from resurrecting the microphone recording after cleanup has already run. If microphone initialization fails or throws, acquired resources (like keep-awake locks and the remote desktop session) are properly rolled back. * Make keep-awake acquisition best-effort with a bounded startup timeout - Recording start no longer blocks (or fails) on keep-awake acquisition: a hung or failing native call is capped at a short budget and logged instead of delaying or aborting dictation. - Add native-call timeouts, orphan-tag tracking, and reacquire/drain logic in mobile-dictation-keep-awake.ts so Activity recreation on Android and stale tags no longer wedge the keep-awake queue. - Add useMobileDictationForegroundKeepAwake to refresh the wake tag on Android foreground and retry failed refreshes/deactivations. - Hold the wake tag through chunk drain and the finish RPC so a screen lock can't suspend the app before the transcript arrives, and keep cleanup running even if native recording shutdown throws. - Loosen expo-keep-awake to a caret range to unblock the patch pulling in these native fixes. * Fix cancellation races in mobile dictation keep-awake handling - Run wake-lock release and dictation cancel concurrently on stale starts so a hung acquisition no longer delays the native cancel - Guard foreground reacquire retries with a run token so a stale retry chain can't deactivate a wake lock reacquired by a newer AppState transition * Update source invariant test for concurrent stale-start cleanup Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 3 + .../src/hooks/mobile-dictation-audio-chunk.ts | 52 +++ .../mobile-dictation-desktop-start.test.ts | 231 +++++++++ .../hooks/mobile-dictation-desktop-start.ts | 142 ++++++ .../mobile-dictation-foreground-keep-awake.ts | 70 +++ .../hooks/mobile-dictation-keep-awake.test.ts | 442 ++++++++++++++++++ .../src/hooks/mobile-dictation-keep-awake.ts | 236 ++++++++++ .../hooks/mobile-dictation-session-state.ts | 65 +++ .../hooks/use-mobile-dictation-source.test.ts | 253 +++++++++- mobile/src/hooks/use-mobile-dictation.ts | 229 +++++---- 11 files changed, 1594 insertions(+), 130 deletions(-) create mode 100644 mobile/src/hooks/mobile-dictation-audio-chunk.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.ts create mode 100644 mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-session-state.ts diff --git a/mobile/package.json b/mobile/package.json index 9f91777a842..cbbc8c86bed 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -35,6 +35,7 @@ "expo-haptics": "^55.0.14", "expo-image-manipulator": "^55.0.17", "expo-image-picker": "^55.0.20", + "expo-keep-awake": "~55.0.8", "expo-linking": "^55.0.15", "expo-modules-core": "~55.0.25", "expo-network": "~55.0.14", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 496855fc266..aafe638813f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: expo-image-picker: specifier: ^55.0.20 version: 55.0.20(expo@55.0.27) + expo-keep-awake: + specifier: ~55.0.8 + version: 55.0.8(expo@55.0.27)(react@19.2.6) expo-linking: specifier: ^55.0.15 version: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts new file mode 100644 index 00000000000..792292e6c16 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -0,0 +1,52 @@ +import { + MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, + MOBILE_DICTATION_PCM_SAMPLE_RATE +} from './mobile-dictation-pending-audio-budget' +import { bytesToBase64 } from './mobile-dictation-session-state' +import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio' +import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import type { RpcClient } from '../transport/rpc-client' + +type MobileDictationAudioChunkQueue = { + pendingChunks: Set> + pendingAudioBudget: MobileDictationPendingAudioBudget + shouldReleaseBudget: (dictationId: string) => boolean + failActiveDictation: (dictationId: string, err: unknown) => void +} + +export function enqueueMobileDictationAudioChunk( + client: RpcClient, + dictationId: string, + event: MicrophoneDataEvent, + queue: MobileDictationAudioChunkQueue +): void { + const raw = event.data + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) + const byteLength = bytes.byteLength + if (!queue.pendingAudioBudget.tryReserve(byteLength)) { + queue.failActiveDictation( + dictationId, + new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) + ) + return + } + const sendChunk = client + .sendRequest('speech.dictation.chunk', { + dictationId, + audioBase64: bytesToBase64(bytes), + sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE + }) + .then((response) => { + if (!response.ok) { + throw new Error(response.error.message) + } + }) + .catch((err) => queue.failActiveDictation(dictationId, err)) + .finally(() => { + if (queue.shouldReleaseBudget(dictationId)) { + queue.pendingAudioBudget.release(byteLength) + } + queue.pendingChunks.delete(sendChunk) + }) + queue.pendingChunks.add(sendChunk) +} diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts new file mode 100644 index 00000000000..bdd0b69d963 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +const OK_RESPONSE = { ok: true, result: {} } as const + +type StartHarnessOptions = { + sendRequest?: (method: string) => Promise + acquire?: () => Promise + commitRecordingStart?: () => boolean +} + +function createStartHarness(options: StartHarnessOptions = {}) { + let generation = 1 + let enabled = true + let activeId: string | null = 'dictation-a' + const setIdle = vi.fn() + const release = vi.fn().mockResolvedValue(undefined) + const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true)) + const rollbackRecordingStart = vi.fn() + const sendRequest = vi.fn( + options.sendRequest ?? (async () => OK_RESPONSE) + ) as unknown as RpcClient['sendRequest'] + const client = { sendRequest } as RpcClient + const keepAwakeOwner = { + acquire: vi.fn(options.acquire ?? (async () => undefined)), + release + } as unknown as MobileDictationKeepAwakeOwner + + return { + options: { + client, + dictationId: 'dictation-a', + generation: 1, + getCurrentGeneration: () => generation, + getEnabled: () => enabled, + getActiveId: () => activeId, + clearActiveId: (dictationId: string) => { + if (activeId === dictationId) { + activeId = null + } + }, + setIdle, + keepAwakeOwner, + commitRecordingStart, + rollbackRecordingStart + }, + setNewerStart: () => { + generation = 2 + activeId = 'dictation-b' + }, + setDisabled: () => { + enabled = false + }, + getActiveId: () => activeId, + setIdle, + release, + sendRequest, + commitRecordingStart, + rollbackRecordingStart + } +} + +describe('startMobileDictationDesktopSession', () => { + it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + acquire: async () => setNewerStart() + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('sends desktop cancellation without waiting for a hung keep-awake release', async () => { + vi.useFakeTimers() + try { + let goStale = () => undefined + const harness = createStartHarness({ + acquire: () => { + goStale() + return new Promise(() => undefined) + } + }) + goStale = harness.setNewerStart + // Release queues behind the still-running acquisition, so it never settles. + harness.release.mockReturnValue(new Promise(() => undefined)) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + // Cancellation is dispatched even though release is still pending, so the + // native session tears down without waiting out its own timeout. + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + + void startPromise + } finally { + vi.useRealTimers() + } + }) + + it('returns to idle when disable makes keep-awake acquisition stale', async () => { + let setDisabled = () => undefined + const harness = createStartHarness({ + acquire: async () => setDisabled() + }) + setDisabled = harness.setDisabled + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.getActiveId()).toBeNull() + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('does not hold recording start on a hung keep-awake acquisition', async () => { + vi.useFakeTimers() + try { + const harness = createStartHarness({ + acquire: () => new Promise(() => undefined) + }) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + await expect(startPromise).resolves.toBe(true) + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.release).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('continues dictation when keep-awake acquisition fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const harness = createStartHarness({ + acquire: async () => { + throw new Error('Unable to activate keep awake') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(consoleError).toHaveBeenCalledOnce() + consoleError.mockRestore() + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-a') + expect(harness.release).not.toHaveBeenCalled() + expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + }) + + it('does not surface a desktop-start failure after the start became stale', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setNewerStart() + throw new Error('Desktop start failed') + } + return OK_RESPONSE + } + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('commits recording before returning a current start to the hook', async () => { + const harness = createStartHarness() + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).not.toHaveBeenCalled() + }) + + it('cleans up the keep-awake tag and desktop session when native recording throws', async () => { + const harness = createStartHarness({ + commitRecordingStart: () => { + throw new Error('Audio focus request failed') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Audio focus request failed' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) + + it('rejects and cleans up when the native recorder does not start', async () => { + const harness = createStartHarness({ commitRecordingStart: () => false }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Failed to start microphone recording' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts new file mode 100644 index 00000000000..581b2b99d9f --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -0,0 +1,142 @@ +import { + MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS, + isCurrentMobileDictationStart +} from './mobile-dictation-session-state' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +type StartMobileDictationDesktopSessionOptions = { + client: RpcClient + dictationId: string + generation: number + getCurrentGeneration: () => number + getEnabled: () => boolean + getActiveId: () => string | null + clearActiveId: (dictationId: string) => void + setIdle: () => void + keepAwakeOwner: MobileDictationKeepAwakeOwner + commitRecordingStart: () => boolean + rollbackRecordingStart: () => void +} + +function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean { + return isCurrentMobileDictationStart( + options.getCurrentGeneration(), + options.generation, + options.getEnabled(), + options.getActiveId(), + options.dictationId + ) +} + +function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean { + return options.getCurrentGeneration() === options.generation && options.getEnabled() +} + +function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void { + if (options.getCurrentGeneration() === options.generation) { + options.setIdle() + } +} + +// Cancel a start that went stale mid-startup. The wake-lock release and remote +// cancel are independent, so run them concurrently: awaiting release first can +// queue behind a still-running acquisition and delay cancel for the remainder +// of the native timeout. +async function cancelStaleStart( + options: StartMobileDictationDesktopSessionOptions, + { releaseKeepAwake }: { releaseKeepAwake: boolean } +): Promise { + const { client, dictationId, keepAwakeOwner } = options + options.clearActiveId(dictationId) + setIdleIfGenerationCurrent(options) + const cleanups: Promise[] = [ + client.sendRequest('speech.dictation.cancel', { dictationId }) + ] + if (releaseKeepAwake) { + cleanups.push(keepAwakeOwner.release(dictationId)) + } + await Promise.allSettled(cleanups) +} + +export async function startMobileDictationDesktopSession( + options: StartMobileDictationDesktopSessionOptions +): Promise { + const { client, dictationId, keepAwakeOwner } = options + + try { + const response = await client.sendRequest('speech.dictation.start', { dictationId }) + if (!response.ok) { + throw new Error(response.error.message) + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + options.clearActiveId(dictationId) + await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) + // Awaited cleanup may overlap a newer start; stale work must not reset or + // report over the replacement session. + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: false }) + return false + } + + // Keep-awake is acquired only after the desktop session exists, so stale + // mobile starts can be canceled without holding a screen-lock tag. It is + // best-effort: Android throws with no current Activity, and a screen-lock + // nicety must not abort an otherwise viable dictation — nor delay recording + // past a small budget when native calls hang. A late acquisition finishes in + // the background; the serialized keep-awake queue orders any later release + // after it. + await new Promise((resolve) => { + const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + keepAwakeOwner + .acquire(dictationId) + .catch((err: unknown) => console.error('Keep-awake activation failed', err)) + .finally(() => { + clearTimeout(budgetTimer) + resolve() + }) + }) + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: true }) + return false + } + + try { + // Commit in the same continuation as the final stale check; returning first + // would let a queued cancel resurrect microphone recording after cleanup. + if (!options.commitRecordingStart()) { + throw new Error('Failed to start microphone recording') + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + // Native recording can partially start before throwing, so stop audio before + // releasing the wake tag and remote session. + try { + options.rollbackRecordingStart() + } catch { + // Continue releasing independently owned resources after native audio failure. + } + options.clearActiveId(dictationId) + await Promise.allSettled([ + keepAwakeOwner.release(dictationId), + client.sendRequest('speech.dictation.cancel', { dictationId }) + ]) + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + return true +} diff --git a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts new file mode 100644 index 00000000000..d5962d9d11e --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { AppState, Platform } from 'react-native' +import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' +import type { RefObject } from 'react' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' + +// A transient Activity gap can fail a foreground refresh; retry briefly while +// the same dictation is live instead of waiting for the next foreground. +const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000] + +let globalStaleTagDrainInstalled = false + +// Failed final deactivations must be retried even after every session screen +// unmounts, or a stale native tag keeps the screen awake until app restart. +// Installed once for the app's lifetime; the drain spares still-wanted tags +// and fast-paths to a no-op when nothing is pending. +function installGlobalStaleTagForegroundDrain(): void { + if (globalStaleTagDrainInstalled) { + return + } + globalStaleTagDrainInstalled = true + AppState.addEventListener('change', (state) => { + if (state === 'active') { + void drainMobileDictationKeepAwakeCleanup().catch(() => undefined) + } + }) +} + +export function useMobileDictationForegroundKeepAwake( + keepAwakeOwner: MobileDictationKeepAwakeOwner, + activeIdRef: RefObject +): void { + useEffect(() => { + installGlobalStaleTagForegroundDrain() + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity + // recreation silently drops it mid-dictation; refresh on return to + // active. iOS re-applies natively on foreground. + if (Platform.OS !== 'android') { + return + } + // A retry from an earlier foreground event can outlive a newer reacquire and + // deactivate the recovered tag; a run token invalidated on each AppState + // change and on unmount drops superseded retry chains. + let reacquireRun = 0 + const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => { + void keepAwakeOwner.reacquire(dictationId).catch(() => { + const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] + if (delay === undefined) { + return + } + setTimeout(() => { + if (reacquireRun === run && activeIdRef.current === dictationId) { + reacquireWithRetry(dictationId, attempt + 1, run) + } + }, delay) + }) + } + const sub = AppState.addEventListener('change', (state) => { + const run = ++reacquireRun + const dictationId = activeIdRef.current + if (state === 'active' && dictationId) { + reacquireWithRetry(dictationId, 0, run) + } + }) + return () => { + reacquireRun += 1 + sub.remove() + } + }, [keepAwakeOwner, activeIdRef]) +} diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts new file mode 100644 index 00000000000..362f3a0cf68 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const keepAwake = vi.hoisted(() => ({ + activate: vi.fn<(tag: string) => Promise>(), + deactivate: vi.fn<(tag: string) => Promise>() +})) + +vi.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: keepAwake.activate, + deactivateKeepAwake: keepAwake.deactivate +})) + +import { + MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, + MobileDictationKeepAwakeOwner, + drainMobileDictationKeepAwakeCleanup +} from './mobile-dictation-keep-awake' + +function deferred(): { + promise: Promise + resolve: () => void + reject: (error: Error) => void +} { + let resolvePromise: (() => void) | undefined + let rejectPromise: ((error: Error) => void) | undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + resolve: () => resolvePromise?.(), + reject: (error) => rejectPromise?.(error) + } +} + +describe('MobileDictationKeepAwakeOwner', () => { + beforeEach(() => { + keepAwake.activate.mockReset().mockResolvedValue(undefined) + keepAwake.deactivate.mockReset().mockResolvedValue(undefined) + }) + + it('retries a failed native deactivation after the hook owner is replaced', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + + await firstOwner.acquire('first') + const firstTag = keepAwake.activate.mock.calls[0]?.[0] + expect(firstTag).toContain(':first') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + const replacementOwner = new MobileDictationKeepAwakeOwner() + await replacementOwner.acquire('second') + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + expect(secondTag).toContain(':second') + expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await replacementOwner.release('second') + }) + + it('serializes cancel and restart without letting a stale release deactivate the restart', async () => { + const firstActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => firstActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + + const acquireFirst = owner.acquire('first') + const releaseFirst = owner.release('first') + const acquireSecond = owner.acquire('second') + firstActivation.resolve() + await Promise.all([acquireFirst, releaseFirst, acquireSecond]) + + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + await owner.release('first') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('second') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag) + }) + + it('waits for an in-flight failed release before a replacement owner activates', async () => { + const deactivation = deferred() + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) + + const releaseFirst = firstOwner.release('first') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(keepAwake.deactivate).toHaveBeenCalledOnce() + + const replacementOwner = new MobileDictationKeepAwakeOwner() + const acquireReplacement = replacementOwner.acquire('replacement') + expect(keepAwake.activate).toHaveBeenCalledOnce() + + deactivation.reject(new Error('Activity unavailable')) + await expect(releaseFirst).rejects.toThrow('Activity unavailable') + await acquireReplacement + + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + await replacementOwner.release('replacement') + }) + + it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + + // Both the release deactivate and its trailing drain retry fail. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + const replacementOwner = new MobileDictationKeepAwakeOwner() + await expect(replacementOwner.acquire('second')).resolves.toBeUndefined() + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + // The still-pending first tag drains once a deactivation finally succeeds. + await replacementOwner.release('second') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength( + 4 + ) + }) + + it('times out a never-settling native call instead of wedging the queue', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const hungOwner = new MobileDictationKeepAwakeOwner() + const hungAcquire = hungOwner.acquire('hung') + // Drain microtasks to quiescence so the timeout timer is registered. + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out') + + // The queue must advance, and another owner's drain must spare the + // still-wanted maybe-late activation. + const nextOwner = new MobileDictationKeepAwakeOwner() + await nextOwner.acquire('next') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next') + + // Once its own dictation ends, the orphan gets cleaned. + await hungOwner.release('hung') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength( + 1 + ) + await nextOwner.release('next') + } finally { + vi.useRealTimers() + } + }) + + it('adopts a timed-out activation that lands late while the dictation is live', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('late') + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted, not deactivated: protection stays on for the live dictation. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('late') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength( + 1 + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not let another owner drain a still-wanted timed-out activation', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const ownerA = new MobileDictationKeepAwakeOwner() + const acquireA = ownerA.acquire('wanted') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquireA).rejects.toThrow('Keep-awake native call timed out') + + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted for owner A; released like a normal activation afterwards. + await ownerA.release('wanted') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted')) + ).toHaveLength(1) + await ownerB.release('other') + } finally { + vi.useRealTimers() + } + }) + + it('deactivates a late-landing activation once its dictation has ended', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('ended') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.release('ended') + lateActivation.resolve() + await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)) + expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retries a timed-out final deactivation via the foreground drain', async () => { + vi.useFakeTimers() + try { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('final') + const tag = keepAwake.activate.mock.calls[0]?.[0] + // The release deactivate times out and its trailing drain retry fails. + keepAwake.deactivate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockRejectedValueOnce(new Error('Activity unavailable')) + const release = owner.release('final') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(release).rejects.toThrow('Keep-awake native call timed out') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + + await drainMobileDictationKeepAwakeCleanup() + expect(keepAwake.deactivate).toHaveBeenCalledTimes(3) + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + } finally { + vi.useRealTimers() + } + }) + + it('drains orphaned tags on release, not only on the next acquire', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('orphan') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + // The dictation ends without another acquire; release must still clean. + await owner.release('orphan') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan')) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it('recovers on foreground reacquire after a failed initial acquisition', async () => { + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + const owner = new MobileDictationKeepAwakeOwner() + await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.reacquire('current') + const tag = keepAwake.activate.mock.calls[1]?.[0] + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(tag).toContain(':current') + // A definite rejection activated nothing, so recovery must not deactivate. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('recovers keep-awake on a later reacquire after a failed refresh', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + // Refresh loses the activation: deactivate succeeds, activate rejects. + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('current') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag) + // Only the pre-refresh deactivate ran; recovery must not deactivate again. + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('records new-dictation intent even when previous-tag cleanup fails', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('first') + + // Release and its trailing drain both fail; the owner keeps stale intent. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(owner.release('first')).rejects.toThrow('Activity unavailable') + + // The next acquire's drain and previous-tag cleanup fail too, and the new + // activation itself fails — intent must still be recorded for the heal. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('second') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second') + + await owner.release('second') + }) + + it('keeps tracking a newer timed-out activation when an older one settles late', async () => { + vi.useFakeTimers() + try { + const first = deferred() + keepAwake.activate.mockImplementationOnce(() => first.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + const second = deferred() + keepAwake.activate.mockImplementationOnce(() => second.promise) + const reacquire = owner.reacquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(reacquire).rejects.toThrow('Keep-awake native call timed out') + + // The older activation settles late with a rejection; the newer + // activation's tracking must survive it. + first.reject(new Error('Activity unavailable')) + await vi.advanceTimersByTimeAsync(0) + + await owner.release('stacked') + // Exactly two: the reacquire's deactivate-first pass, plus the release + // drain cleaning the newer entry the stale settle must not have deleted. + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked')) + ).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('reacquires a maybe-active timed-out activation by deactivating first', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('maybe') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.reacquire('maybe') + // The ambiguous activation may be natively live; it must be deactivated + // before the fresh activate so Android re-applies the window flag. + const tag = keepAwake.activate.mock.calls[0]?.[0] + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.release('maybe') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => { + const ownerA = new MobileDictationKeepAwakeOwner() + await ownerA.acquire('live') + const liveTag = keepAwake.activate.mock.calls[0]?.[0] + + // Foreground refresh: the deactivate leg fails; the tag stays native-on + // and the failure surfaces so the caller's bounded retry can kick in. + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // Another owner's drain must spare the still-wanted live tag. + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // The next foreground retries the full deactivate-then-activate refresh. + await ownerA.reacquire('live') + expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2) + + await ownerA.release('live') + await ownerB.release('other') + }) + + it('reacquires by deactivating before activating so Android re-applies the window flag', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + await owner.reacquire('current') + + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.reacquire('other') + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + await owner.release('current') + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts new file mode 100644 index 00000000000..f94e3fb785a --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -0,0 +1,236 @@ +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' + +const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' + +// Native keep-awake promises can be lost during Activity teardown; a bounded +// wait keeps the serialized queue below from wedging dictation until restart. +export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 + +let nextOwnerId = 0 +let keepAwakeOperation: Promise = Promise.resolve() +const activeTags = new Set() +const pendingCleanupTags = new Set() +// Timed-out activations that may still land natively, keyed to a predicate +// saying whether their dictation still wants the tag. +const pendingActivations = new Map boolean>() + +function createOwnerId(): string { + nextOwnerId += 1 + return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}` +} + +function enqueueKeepAwakeOperation(action: () => Promise): Promise { + const operation = keepAwakeOperation.then(action) + keepAwakeOperation = operation.catch(() => undefined) + return operation +} + +const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError' + +function isNativeCallTimeout(err: unknown): boolean { + return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME +} + +function withNativeCallTimeout(nativeCall: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const timeoutError = new Error('Keep-awake native call timed out') + timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME + reject(timeoutError) + }, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + nativeCall.then( + () => { + clearTimeout(timer) + resolve() + }, + (err: unknown) => { + clearTimeout(timer) + reject(err instanceof Error ? err : new Error(String(err))) + } + ) + }) +} + +async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise { + const nativeActivation = activateKeepAwakeAsync(tag) + try { + await withNativeCallTimeout(nativeActivation) + } catch (err) { + // Only a timeout is ambiguous: the activation can still take effect late. + // A definite native rejection activated nothing and needs no cleanup. + if (isNativeCallTimeout(err)) { + // Tracked apart from deactivation retries so drains (including another + // owner's) never turn off an activation its dictation still wants. + pendingActivations.set(tag, isStillWanted) + nativeActivation.then( + () => + void enqueueKeepAwakeOperation(async () => { + // Delete only this activation's own entry — a newer timed-out + // activation of the same tag may have replaced it. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + if (activeTags.has(tag)) { + return + } + if (isStillWanted()) { + // The activation landed late but its dictation is still live; + // adopt it rather than turning off screen-lock protection. + activeTags.add(tag) + return + } + // No owner wants it anymore — the screen must not stay awake. + await deactivateTrackedTag(tag).catch(() => undefined) + }), + () => { + // A late definite rejection means nothing activated after all, but + // spare a newer activation's entry keyed to the same tag. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + } + ) + } + throw err + } + activeTags.add(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function deactivateTrackedTag(tag: string): Promise { + try { + await withNativeCallTimeout(deactivateKeepAwake(tag)) + } catch (err) { + // A replacement hook must be able to retry cleanup after Android replaces + // an Activity and the owner that acquired this tag has unmounted. + pendingCleanupTags.add(tag) + throw err + } + activeTags.delete(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function cleanupPendingTags(): Promise { + const staleTags = new Set(pendingCleanupTags) + for (const [tag, isStillWanted] of pendingActivations) { + // A still-wanted timed-out activation is not an orphan: deactivating it + // would turn off screen-lock protection for a live dictation. + if (!isStillWanted()) { + pendingActivations.delete(tag) + staleTags.add(tag) + } + } + if (staleTags.size === 0) { + return + } + // Retry concurrently so N stale tags cost one timeout window, not N, and + // swallow failures: a stale tag that still cannot be deactivated must not + // fail the fresh acquire that triggered this retry; it stays queued. + await Promise.allSettled( + Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined)) + ) +} + +export class MobileDictationKeepAwakeOwner { + private readonly ownerId = createOwnerId() + private acquiredTag: string | null = null + + acquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag && !activeTags.has(this.acquiredTag)) { + this.acquiredTag = null + } + if (this.acquiredTag === tag) { + return + } + if (this.acquiredTag) { + const previousTag = this.acquiredTag + this.acquiredTag = null + // Best-effort: a failed previous-tag cleanup is queued for retry and + // must not block recording intent for the new dictation below. + await deactivateTrackedTag(previousTag).catch(() => undefined) + } + // Record ownership before the native call: acquiredTag is intent while + // activeTags is native state, so a failed initial activation can still + // be healed by a later foreground reacquire. + this.acquiredTag = tag + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated + // Activity silently loses it while native tags persist — and native activate + // skips re-applying the flag while any tag remains, so deactivate first. + reacquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag !== tag) { + return + } + // A timed-out activation may be natively active too, and Android only + // re-applies the window flag from an empty tag set — deactivate both. + if (activeTags.has(tag) || pendingActivations.has(tag)) { + try { + await deactivateTrackedTag(tag) + } catch (err) { + // A still-live tag must not sit in the orphan pool where another + // owner's drain would turn it off without reactivating; keep it in + // the wanted pool and surface the failure so the caller can retry + // before the next foreground event. + pendingCleanupTags.delete(tag) + pendingActivations.set(tag, () => this.acquiredTag === tag) + throw err + } + } + // Also recovers an activation lost to an earlier native failure, so a + // later foreground event can restore keep-awake instead of no-oping. + // Known gap: if another expo-keep-awake owner exists (e.g. dev-build + // dev tools), the native module never empties its tag set, so the + // deactivate/activate cycle cannot re-apply the Android window flag. + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + release(dictationId?: string): Promise { + const targetTag = dictationId ? this.createTag(dictationId) : null + return enqueueKeepAwakeOperation(async () => { + try { + const tag = this.acquiredTag + if (!tag || (targetTag && tag !== targetTag)) { + return + } + if (!activeTags.has(tag)) { + this.acquiredTag = null + return + } + await deactivateTrackedTag(tag) + this.acquiredTag = null + } finally { + // Drain after the owner-local unset so this owner's own timed-out + // activation is no longer wanted and gets cleaned here — an acquire + // may never happen again this session. Still-wanted tags of other + // live dictations are spared by the drain itself. + await cleanupPendingTags() + } + }) + } + + private createTag(dictationId: string): string { + return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}` + } +} + +export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner { + return new MobileDictationKeepAwakeOwner() +} + +// Foreground is the retry point for wake tags whose final deactivation timed +// out after a dictation ended — otherwise nothing runs until the next one. +export function drainMobileDictationKeepAwakeCleanup(): Promise { + return enqueueKeepAwakeOperation(cleanupPendingTags) +} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts new file mode 100644 index 00000000000..bfc2dbd8d29 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -0,0 +1,65 @@ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because +// Metro cannot resolve Node builtins in a React Native bundle. +import { Buffer } from 'buffer' + +import type { RpcClient } from '../transport/rpc-client' + +export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' + +export type UseMobileDictationOptions = { + client: RpcClient | null + enabled: boolean + onTranscript: (text: string) => void + onError?: (error: Error) => void +} + +export type UseMobileDictationResult = { + status: DictationStatus + isStarting: boolean + isRecording: boolean + isProcessing: boolean + error: string | null + start: () => Promise + stop: () => Promise + cancel: () => Promise +} + +export const DICTATION_FINISH_TIMEOUT_MS = 75_000 + +// Recording start waits at most this long for the best-effort wake tag; a +// slow or hung native keep-awake module must not hold the mic in 'starting'. +export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500 + +export function bytesToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +export function createMobileDictationId(): string { + return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function isCurrentMobileDictationStart( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + dictationId: string +): boolean { + return currentGeneration === generation && enabled && activeId === dictationId +} + +export function isCurrentMobileDictationFinish( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + finishingId: string | null, + dictationId: string +): boolean { + return ( + currentGeneration === generation && + enabled && + activeId === dictationId && + finishingId === dictationId + ) +} diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 6319e2d9b5a..0649e7ee54b 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') +const audioChunkSource = readFileSync( + new URL('./mobile-dictation-audio-chunk.ts', import.meta.url), + 'utf8' +) +const keepAwakeSource = readFileSync( + new URL('./mobile-dictation-keep-awake.ts', import.meta.url), + 'utf8' +) +const desktopStartSource = readFileSync( + new URL('./mobile-dictation-desktop-start.ts', import.meta.url), + 'utf8' +) +const sessionStateSource = readFileSync( + new URL('./mobile-dictation-session-state.ts', import.meta.url), + 'utf8' +) +const foregroundKeepAwakeSource = readFileSync( + new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url), + 'utf8' +) + +function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { + const start = sourceText.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = sourceText.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return sourceText.slice(start, end) +} function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) + return sliceSource(source, startPattern, endPattern) +} + +function sliceDesktopStartBetween(startPattern: string, endPattern: string): string { + return sliceSource(desktopStartSource, startPattern, endPattern) } describe('useMobileDictation source invariants', () => { @@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => { }) it('reserves pending audio bytes before encoding microphone chunks', () => { - const microphoneEffect = sliceBetween( - "addExpoTwoWayAudioEventListener('onMicrophoneData'", - 'return () => sub.remove()' + const microphoneEffect = sliceSource( + audioChunkSource, + 'export function enqueueMobileDictationAudioChunk', + ' queue.pendingChunks.add(sendChunk)' ) const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)') @@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => { expect(encodeIndex).toBeGreaterThanOrEqual(0) expect(reserveIndex).toBeLessThan(encodeIndex) expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)') + expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)') + expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event') + }) + + it('reuses audio chunk queue wiring across microphone events', () => { + const queueIndex = source.indexOf('const audioChunkQueue =') + const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'") + + expect(queueIndex).toBeGreaterThanOrEqual(0) + expect(queueIndex).toBeLessThan(listenerIndex) + expect(source).toContain( + 'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)' + ) }) it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { @@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => { expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) }) + + it('keeps mobile dictation keep-awake ownership beside the hook', () => { + expect(source).toMatch( + /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ + ) + expect(source).toContain( + 'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])' + ) + expect(keepAwakeSource).toContain('activateKeepAwakeAsync') + expect(keepAwakeSource).toContain('deactivateKeepAwake') + expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/) + }) + + it('acquires keep-awake only after desktop start and stale-start guards', () => { + const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =') + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const desktopStartIndex = startBody.indexOf( + "client.sendRequest('speech.dictation.start', { dictationId })" + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') + const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)') + + expect(desktopStartIndex).toBeGreaterThanOrEqual(0) + expect(acquireIndex).toBeGreaterThan(desktopStartIndex) + expect(desktopSessionIndex).toBeGreaterThanOrEqual(0) + expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex) + expect(hookStartBody).toContain('commitRecordingStart: () => {') + expect(startBody).toContain('options.commitRecordingStart()') + + const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex) + expect(beforeAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(sessionStateSource).toContain( + 'currentGeneration === generation && enabled && activeId === dictationId' + ) + }) + + it('re-checks stale-start guards after awaited keep-awake acquisition', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const returnStartedIndex = startBody.indexOf('return true') + const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) + + expect(afterAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })') + + // The stale-start cleanup must release the wake tag and cancel the desktop + // session (concurrently, so a hung acquisition can't delay the cancel). + const cancelStaleStartBody = sliceDesktopStartBetween( + 'async function cancelStaleStart', + 'export async function startMobileDictationDesktopSession' + ) + expect(cancelStaleStartBody).toContain( + "client.sendRequest('speech.dictation.cancel', { dictationId })" + ) + expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))') + expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)') + }) + + it('treats keep-awake acquisition as best-effort for the desktop session', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + expect(acquireIndex).toBeGreaterThanOrEqual(0) + const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) + expect(staleCheckIndex).toBeGreaterThan(acquireIndex) + + // An acquisition failure must not cancel the dictation or surface an error. + const acquireChain = startBody.slice(acquireIndex, staleCheckIndex) + expect(acquireChain).toContain('.catch(') + expect(acquireChain).not.toContain('throw') + }) + + it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan( + closeAudio.indexOf('void keepAwakeOwner.release') + ) + expect(closeAudio).toContain('.catch(() => undefined)') + + const cleanupSlices = [ + sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), + sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'), + sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') + ] + + for (const cleanupSlice of cleanupSlices) { + expect(cleanupSlice).toContain('closeDictationAudio(dictationId)') + } + + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan( + stopBody.indexOf('await Promise.allSettled') + ) + // The wake tag must be held through chunk drain and the finish RPC so a + // screen lock cannot suspend the app before the transcript arrives. + expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + expect(stopBody.indexOf('} finally {')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + }) + + it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => { + expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)') + expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'") + expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)') + // A transiently failing refresh retries while the dictation is live. + expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]') + expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId') + // Stale-tag retries survive hook unmount via a module-level listener. + expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()') + expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()') + + // Native activate skips re-applying the window flag while any tag remains, + // so reacquire must deactivate before activating. + const reacquireBody = sliceSource( + keepAwakeSource, + 'reacquire(dictationId: string)', + 'release(dictationId?: string)' + ) + expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan( + reacquireBody.indexOf('await activateTrackedTag(tag,') + ) + }) + + it('keeps cleanup going when native recording shutdown throws', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + const toggleIndex = closeAudio.indexOf('toggleRecording(false)') + const catchIndex = closeAudio.indexOf('} catch', toggleIndex) + const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release') + expect(toggleIndex).toBeGreaterThanOrEqual(0) + expect(catchIndex).toBeGreaterThan(toggleIndex) + expect(catchIndex).toBeLessThan(releaseIndex) + + // stop()'s recording shutdown sits inside the try so a native throw still + // runs the finally release and error cleanup. + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0) + expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)')) + }) + + it('routes disabled state and audio interruptions through cancel cleanup', () => { + const interruptionEffect = sliceBetween( + "addExpoTwoWayAudioEventListener('onAudioInterruption'", + 'return () => sub.remove()' + ) + const disabledEffect = sliceBetween( + 'useEffect(() => {\n if (!enabled) {', + ' }, [cancel, enabled])' + ) + + expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'") + expect(interruptionEffect).toContain('void cancel()') + expect(disabledEffect).toContain('void cancel()') + }) + + it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => { + expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()') + expect(keepAwakeSource).toContain( + '`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`' + ) + expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise = Promise.resolve()') + expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set()') + expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3) + expect(keepAwakeSource).toContain( + 'const targetTag = dictationId ? this.createTag(dictationId) : null' + ) + expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))') + expect(keepAwakeSource).toContain('await cleanupPendingTags()') + }) }) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index a1028cf9205..648048dc451 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,7 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { addExpoTwoWayAudioEventListener, initialize, @@ -9,45 +6,27 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import type { RpcClient } from '../transport/rpc-client' +import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk' +import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake' import { - MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, - MOBILE_DICTATION_PCM_SAMPLE_RATE, - MobileDictationPendingAudioBudget -} from './mobile-dictation-pending-audio-budget' + DICTATION_FINISH_TIMEOUT_MS, + createMobileDictationId, + isCurrentMobileDictationFinish +} from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { + DictationStatus, + UseMobileDictationOptions, + UseMobileDictationResult +} from './mobile-dictation-session-state' -type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' - -type UseMobileDictationOptions = { - client: RpcClient | null - enabled: boolean - onTranscript: (text: string) => void - onError?: (error: Error) => void -} - -export type UseMobileDictationResult = { - status: DictationStatus - isStarting: boolean - isRecording: boolean - isProcessing: boolean - error: string | null - start: () => Promise - stop: () => Promise - cancel: () => Promise -} - -const DICTATION_FINISH_TIMEOUT_MS = 75_000 - -function bytesToBase64(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('base64') -} - -function createDictationId(): string { - return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` -} +export type { UseMobileDictationResult } from './mobile-dictation-session-state' export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options + const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, []) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) @@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil onErrorRef.current?.(normalized) }, []) + const closeDictationAudio = useCallback( + (dictationId?: string | null) => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + try { + toggleRecording(false) + } catch (err) { + // Cleanup must keep going when native recording shutdown throws, or + // the wake tag and dictation state would leak. + console.error('Failed to stop microphone recording', err) + } + void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) + }, + [keepAwakeOwner] + ) + const failActiveDictation = useCallback( (dictationId: string, err: unknown) => { const client = clientRef.current @@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } activeIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } reportError(err) }, - [reportError] + [closeDictationAudio, reportError] ) useEffect(() => { + // Microphone events are a hot path; reuse this wiring instead of allocating + // a queue object and release predicate for every audio chunk. + const audioChunkQueue = { + pendingChunks: pendingChunksRef.current, + pendingAudioBudget: pendingAudioBudgetRef.current, + shouldReleaseBudget: (id: string) => + activeIdRef.current === id || finishingIdRef.current === id, + failActiveDictation + } const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const client = clientRef.current const dictationId = activeIdRef.current if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) { return } - const raw = event.data - const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) - const byteLength = bytes.byteLength - if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) { - failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)) - return - } - const sendChunk = client - .sendRequest('speech.dictation.chunk', { - dictationId, - audioBase64: bytesToBase64(bytes), - sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE - }) - .then((response) => { - if (!response.ok) { - throw new Error(response.error.message) - } - }) - .catch((err) => failActiveDictation(dictationId, err)) - .finally(() => { - if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) { - pendingAudioBudgetRef.current.release(byteLength) - } - pendingChunksRef.current.delete(sendChunk) - }) - pendingChunksRef.current.add(sendChunk) + enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue) }) return () => sub.remove() }, [failActiveDictation, reportError]) @@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error('Failed to initialize microphone') } - const dictationId = createDictationId() + const dictationId = createMobileDictationId() activeIdRef.current = dictationId - try { - const response = await client.sendRequest('speech.dictation.start', { dictationId }) - if (!response.ok) { - throw new Error(response.error.message) - } - } catch (err) { - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - setStatus('idle') - throw err - } - if ( - generationRef.current !== generation || - !enabledRef.current || - activeIdRef.current !== dictationId - ) { - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - setStatus('idle') - return - } - acceptingChunksRef.current = true - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(true) - setStatus('recording') - }, []) + await startMobileDictationDesktopSession({ + client, + dictationId, + generation, + getCurrentGeneration: () => generationRef.current, + getEnabled: () => enabledRef.current, + getActiveId: () => activeIdRef.current, + clearActiveId: (id) => { + if (activeIdRef.current === id) { + activeIdRef.current = null + } + }, + setIdle: () => setStatus('idle'), + keepAwakeOwner, + commitRecordingStart: () => { + acceptingChunksRef.current = true + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + if (!toggleRecording(true)) { + return false + } + setStatus('recording') + return true + }, + rollbackRecordingStart: () => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + toggleRecording(false) + } + }) + }, [keepAwakeOwner]) const stop = useCallback(async () => { const client = clientRef.current @@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = dictationId setStatus('processing') acceptingChunksRef.current = false - toggleRecording(false) try { + // Inside the try so a throwing native shutdown still runs the finally + // release and error cleanup. + toggleRecording(false) await Promise.allSettled(Array.from(pendingChunksRef.current)) if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error(response.error.message) } if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -257,11 +245,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } catch (err) { failActiveDictation(dictationId, err) } finally { + // Hold the wake tag through chunk drain and the finish RPC: a screen + // lock mid-processing suspends the app and loses the transcript. + void keepAwakeOwner.release(dictationId).catch(() => undefined) if (finishingIdRef.current === dictationId) { finishingIdRef.current = null } } - }, [failActiveDictation]) + }, [failActiveDictation, keepAwakeOwner]) const cancel = useCallback(async () => { const client = clientRef.current @@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } setStatus('idle') setError(null) - }, []) + }, [closeDictationAudio]) + + useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef) useEffect(() => { const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => { @@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) void tearDown() if (clientRef.current && dictationId) { void clientRef.current @@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil .catch(() => undefined) } } - }, []) + }, [closeDictationAudio]) return { status, From 31cff75a7ba00e0cca5051893ebff48bea5d2a10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:34:49 -0700 Subject: [PATCH 036/138] feat(agent-status): show Claude subagent child rows and gate premature done (#8211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): show Claude subagent child rows and gate premature done A Claude pane that spawned background subagents/teammates showed a green done check the moment the lead's turn ended, even while a background review loop was still running. Orca now tracks the pane's live children from Claude hook events and: - keeps the pane 'working' while at least one child is working (Stop is gated; Claude wakes the lead when a child finishes, so the pane resolves to done on the follow-up Stop with an empty roster) - renders the children as indented child rows under the pane's sidebar row (name/type + working/idle dot), reusing the existing lineage UI Tracking is lifecycle-primary: SubagentStart/SubagentStop/TeammateIdle (newly registered hooks) plus child-origin tool events (they carry agent_id) own the roster. Stop's background_tasks is folded only where unambiguous — verified live on Claude Code 2.1.207 that teammates report status "running" while idle-alive and their task ids never match lifecycle agent_ids, so the list cannot decide teammate working-ness. Child-origin events no longer overwrite the lead's tool/prompt caches (a live AskUserQuestion card survives child churn); a child's own PermissionRequest records waitingAgentId so only that child's progress or death clears the wait. The interrupted flag survives the gated window, inferred interrupts sync the lead record and refuse while a child works, and hydration reseeds the roster after a restart. * fix(agent-status): drop identity icon on subagent child rows The child's agentType carries its NAME (e.g. "pr-reviewer"), which is not an iconable agent and rendered the unknown "?" glyph. Nesting under the parent row already conveys identity. * fix(agent-status): restore displaced lead state and reconcile phantom subagents Four review findings from the adversarial pass on the subagent child-row feature: - Stash the lead state a child-induced wait displaces (ClaudeLeadTurnState.stateBeforeWait) and restore it when the wait clears, instead of inventing 'working' — a lead that had already stopped left the pane spinning forever after the roster drained, since the done-gate only ever downgrades done → working. - Tag snapshot-seeded and background_tasks-recreated roster entries (backgroundTasksAuthoritative) and demote them when a PRESENT background_tasks list omits their id. A phantom child seeded before a restart could otherwise gate the pane 'working' indefinitely in teams sessions, whose task list is never empty. Live activity clears the tag so lifecycle-tracked teammates keep their state. - Match teammate ids with a hyphen-free suffix after `a-` so TeammateIdle for "lane" cannot idle "lane-hooks"'s rows or clear its pending permission wait. - Route turn-boundary events (Stop/StopFailure/UserPromptSubmit) that carry a KNOWN child agent_id through the child-driven re-emit instead of adopting them as lead state, and tie the prompt-cache new-turn reset to lead-origin events so child refreshes can't blank the prompt label. --------- Co-authored-by: Brennan Benson --- src/main/agent-hooks/server.test.ts | 83 ++++ src/main/agent-hooks/server.ts | 35 +- src/main/claude/hook-service.test.ts | 3 + src/main/claude/hook-settings.ts | 8 + .../dashboard/DashboardAgentRow.tsx | 20 +- .../DashboardAgentRowTrailingControls.tsx | 16 +- .../components/dashboard/useDashboardData.ts | 7 +- .../sidebar/useWorktreeAgentRows.test.ts | 72 +++ .../components/sidebar/worktree-agent-rows.ts | 3 + .../worktree-card-compact-agent-row.tsx | 17 +- .../sidebar/worktree-subagent-child-rows.ts | 63 +++ src/renderer/src/hooks/useIpcEvents.ts | 5 +- src/renderer/src/store/slices/agent-status.ts | 8 + src/shared/agent-hook-listener.test.ts | 463 ++++++++++++++++++ src/shared/agent-hook-listener.ts | 357 +++++++++++++- .../agent-status-field-normalization.ts | 213 ++++++++ src/shared/agent-status-types.test.ts | 54 ++ src/shared/agent-status-types.ts | 279 ++++------- src/shared/claude-subagent-roster.test.ts | 261 ++++++++++ src/shared/claude-subagent-roster.ts | 257 ++++++++++ 20 files changed, 2004 insertions(+), 220 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts create mode 100644 src/shared/agent-status-field-normalization.ts create mode 100644 src/shared/claude-subagent-roster.test.ts create mode 100644 src/shared/claude-subagent-roster.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index bf3cf0c8f84..9fbb358dfae 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -143,6 +143,89 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not infer an interrupt while a subagent child is still working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'review loop', + agentType: 'claude', + // Why: a working pane can be child-driven (lead already idle). + // Ctrl+C does not stop background children, so no terminal done + // may be inferred while one is still running. + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'review loop', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + vi.useRealTimers() + } + }) + + it('carries idle subagent rows through an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'wrap up', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'wrap up', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })] + }) + } finally { + vi.useRealTimers() + } + }) + it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index da33b4231cb..45fdf0362a6 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -26,12 +26,14 @@ import { getEndpointFileName, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, + markClaudeLeadTurnInterrupted, MAX_PANE_KEY_LEN, normalizeHookPayload, parseFormEncodedBody, readRequestBody, resolveHookSource, preparePendingGrokResultDiscovery, + seedClaudeSubagentRosterFromSnapshots, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -253,6 +255,10 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } +// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`: +// OSC payloads never carry them, and including the field would make every +// hook-cached entry with child rows non-equivalent — the OSC ping would then +// apply and wipe the roster. Do not reuse this for hook-path comparisons. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, b: ParsedAgentStatusPayload @@ -555,7 +561,20 @@ export class AgentHookServer { ) { return false } + // Why: a 'working' pane can be child-driven (lead already idle, background + // subagent running). Ctrl+C at the TUI does not stop background children, + // so inferring a terminal done here would wrongly retire live child rows; + // their own hook events keep the row truthful instead. + if (payload.subagents?.some((subagent) => subagent.state === 'working')) { + return false + } + // Why: keep the listener's Claude lead-turn record in sync — a later + // child lifecycle event would otherwise re-emit the stale pre-interrupt + // 'working' lead state and resurrect the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -566,7 +585,10 @@ export class AgentHookServer { state: 'done', prompt: payload.prompt, agentType, - interrupted: true + interrupted: true, + // Why: idle children are display state; dropping them on an inferred + // interrupt would blank the child rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) console.debug('[agent-hooks] inferred interrupted agent status', { @@ -1558,6 +1580,17 @@ export class AgentHookServer { const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + // Why: the in-memory subagent roster died with the previous process. + // Reseed it from the persisted snapshot so the next teammate-bearing + // Stop (whose task ids never match lifecycle ids) doesn't silently + // drop the replayed child rows. + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } hydrated += 1 } else { dropped += 1 diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index ed77f48e6e0..6400efe7357 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -255,6 +255,9 @@ describe('ClaudeHookService.installRemote', () => { 'UserPromptSubmit', 'Stop', 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'TeammateIdle', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index cb754fce5d0..0c7012b383a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -32,6 +32,14 @@ export const CLAUDE_EVENTS = [ // Why: OpenClaude skips normal Stop hooks after API/model errors and emits // StopFailure instead; without this hook Orca leaves the turn spinning. { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps + // a pane 'working' while background children outlive the lead's turn. + // TeammateIdle is required because idle-but-alive teammates still report + // status "running" in Stop's background_tasks. + // Older Claude builds ignore unregistered event names (StopFailure precedent). + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 692fdb0279c..1a84a985ba5 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -50,6 +50,12 @@ function formatTimeAgo(ts: number, now: number): string { // drift away from the true transition moment. For past dones, stateHistory // entries already store the per-transition `startedAt` so we read it directly. function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { + // Why: idle subagent child rows are alive-but-idle (teammates persist + // between turns) — reading their synthetic entry as "done Xm ago" would + // mislabel a live teammate as finished. + if (agent.rowSource === 'subagent' && agent.state === 'idle') { + return null + } const entry = agent.entry if (entry.state === 'done') { return entry.stateStartedAt @@ -163,9 +169,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const handleActivate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - onActivate(agent.tab.id, agent.paneKey) + // Why: subagent child rows have no pane of their own; they focus the + // parent pane whose session spawned them. + onActivate(agent.tab.id, agent.activationPaneKey ?? agent.paneKey) }, - [onActivate, agent.tab.id, agent.paneKey] + [onActivate, agent.tab.id, agent.activationPaneKey, agent.paneKey] ) const handleSendTargetClickCapture = useCallback( (e: React.MouseEvent) => { @@ -332,8 +340,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ on the top row. The sub-rows (tool step, assistant response) are about the same agent and do not need the icon repeated next to them — keeping the icon only on the prompt row lets the sub-rows - indent under the prompt text cleanly. */} - {!hideIdentityIcon && ( + indent under the prompt text cleanly. Subagent child rows carry + the child's NAME in agentType (not an iconable agent — it would + render the unknown "?" glyph), and nesting under the parent + already conveys identity. */} + {!hideIdentityIcon && agent.rowSource !== 'subagent' && ( @@ -385,6 +396,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ relativeTimestamp={relativeTimestamp} expanded={expanded} hideExpand={hideExpand} + hideDismiss={agent.rowSource === 'subagent'} sendTargetStatus={sendTargetStatus} onDismiss={onDismiss} onToggleExpanded={handleToggleExpanded} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx index 13b3bafae6f..f194abbe508 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx @@ -8,6 +8,9 @@ type DashboardAgentRowTrailingControlsProps = { relativeTimestamp: string | null expanded: boolean hideExpand: boolean + /** Subagent child rows have no store entry of their own to dismiss — + * offering the X would be a silent no-op. */ + hideDismiss?: boolean sendTargetStatus?: 'eligible' | 'disabled' | 'sending' onDismiss: (paneKey: string) => void onToggleExpanded: () => void @@ -19,6 +22,7 @@ export function DashboardAgentRowTrailingControls({ relativeTimestamp, expanded, hideExpand, + hideDismiss = false, sendTargetStatus, onDismiss, onToggleExpanded, @@ -86,9 +90,17 @@ export function DashboardAgentRowTrailingControls({ {translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')} )} + {!sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( + + {relativeTimestamp} + + )} {/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X is visible by default, so the timestamp must yield there too. */} - {!sendTargetStatus && relativeTimestamp !== null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp !== null && ( )} - {!sendTargetStatus && relativeTimestamp === null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp === null && (
- diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts new file mode 100644 index 00000000000..b7b0c0dd34e --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + getCrashReportCopySubmissionFailure, + getCrashReportSubmitFailureNotice, + getCrashReportSubmitWarningNotice +} from './crash-report-submit-notice' + +describe('crash report submit notices', () => { + it('builds an allow-listed copy context with the exact sanitized failure reasons', () => { + expect( + getCrashReportCopySubmissionFailure({ + error: 'request failed at C:\\Users\\alice\\Orca', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'attachment timeout token=super-secret-value' + } + }) + ).toEqual({ + error: 'request failed at [redacted-path]', + diagnosticContext: { + status: 'not_uploaded', + reason: 'attachment timeout token=[redacted]' + } + }) + }) + + it('keeps the returned error and points attached-log failures to the no-log retry', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'status 413' }, true) + + expect(notice).toEqual({ + title: "Crash report wasn't sent", + description: + 'status 413. Uncheck "Attach recent diagnostic logs" and try again, or copy the details.', + actionLabel: 'Copy Details' + }) + }) + + it('uses connection guidance when diagnostic logs were already excluded', () => { + const notice = getCrashReportSubmitFailureNotice({ error: 'network unreachable' }, false) + + expect(notice.description).toBe( + 'network unreachable. Check your connection and try again, or copy the details.' + ) + expect(notice.description).not.toContain('Uncheck') + }) + + it('preserves an uploaded diagnostic ticket alongside the core failure', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'status 502', + diagnosticBundle: { + status: 'uploaded', + ticketId: 'ticket-123', + bundleSubmissionId: 'bundle-123', + bytes: 42, + spanCount: 2 + } + }, + true + ) + + expect(notice.description).toContain('status 502.') + expect(notice.description).toContain( + 'Diagnostic ticket ticket-123 was uploaded but not linked.' + ) + }) + + it('shows both failures when the attachment was already omitted', () => { + const notice = getCrashReportSubmitFailureNotice( + { + error: 'fallback host unreachable', + diagnosticBundle: { + status: 'not_uploaded', + reason: 'diagnostic log attachment failed: request timed out after 60 seconds' + } + }, + true + ) + + expect(notice.description).toContain('fallback host unreachable.') + expect(notice.description).toContain( + 'Diagnostic logs were not attached: diagnostic log attachment failed: request timed out after 60 seconds.' + ) + expect(notice.description).toContain('Check your connection') + expect(notice.description).not.toContain('Uncheck') + }) + + it('safely surfaces Error messages and ignores unhelpful rejected values', () => { + expect( + getCrashReportSubmitFailureNotice({ error: new Error('IPC channel closed') }, false) + .description + ).toContain('IPC channel closed.') + expect( + getCrashReportSubmitFailureNotice({ error: { unexpected: true } }, false).description + ).toContain('The crash report request failed before it returned a reason.') + }) + + it('redacts paths and tokens before displaying transport errors', () => { + const token = `ghp_${'a'.repeat(30)}` + const notice = getCrashReportSubmitFailureNotice( + { + error: `request failed at C:\\Users\\alice\\Orca\\crash-reports.json token=${token}` + }, + false + ) + + expect(notice.description).not.toContain('alice') + expect(notice.description).not.toContain(token) + expect(notice.description).toContain('[redacted') + }) + + it('warns only when an opted-in diagnostic bundle was not attached', () => { + const result = { + ok: true as const, + report: null, + diagnosticBundle: { + status: 'not_uploaded' as const, + reason: 'bundle collection failed' + } + } + + expect(getCrashReportSubmitWarningNotice(result, true)).toEqual({ + title: 'Crash report sent without diagnostic logs', + description: 'Diagnostic logs were not attached: bundle collection failed' + }) + expect(getCrashReportSubmitWarningNotice(result, false)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/crash-report/crash-report-submit-notice.ts b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts new file mode 100644 index 00000000000..29824b60416 --- /dev/null +++ b/src/renderer/src/components/crash-report/crash-report-submit-notice.ts @@ -0,0 +1,134 @@ +import { translate } from '@/i18n/i18n' +import { + sanitizeCrashReportString, + type CrashReportCopySubmissionFailure, + type CrashReportDiagnosticBundle, + type CrashReportSubmitResult +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID = 'crash-report-submit-failure' + +export type CrashReportSubmitFailureNotice = { + title: string + description: string + actionLabel: string +} + +export type CrashReportSubmitWarningNotice = { + title: string + description: string +} + +type CrashReportSubmitFailureLike = { + error: unknown + diagnosticBundle?: CrashReportDiagnosticBundle +} + +function normalizedFailureMessage(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + const sanitized = sanitizeCrashReportString(message).trim() + return ( + sanitized || + translate( + 'auto.components.crash.report.submit.notice.unknownError', + 'The crash report request failed before it returned a reason.' + ) + ) +} + +function asSentence(message: string): string { + return /[.!?]$/.test(message) ? message : `${message}.` +} + +export function getCrashReportCopySubmissionFailure( + failure: CrashReportSubmitFailureLike +): CrashReportCopySubmissionFailure { + const diagnosticContext = + failure.diagnosticBundle?.status === 'uploaded' + ? { + status: 'uploaded' as const, + ticketId: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) + } + : failure.diagnosticBundle?.status === 'not_uploaded' + ? { + status: 'not_uploaded' as const, + reason: normalizedFailureMessage(failure.diagnosticBundle.reason) + } + : undefined + return { + error: normalizedFailureMessage(failure.error), + ...(diagnosticContext ? { diagnosticContext } : {}) + } +} + +export function getCrashReportSubmitFailureNotice( + failure: CrashReportSubmitFailureLike, + includeDiagnosticLogs: boolean +): CrashReportSubmitFailureNotice { + const ticketDetail = + failure.diagnosticBundle?.status === 'uploaded' + ? translate( + 'auto.components.crash.report.submit.notice.ticketUploaded', + 'Diagnostic ticket {{value0}} was uploaded but not linked.', + { value0: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) } + ) + : null + const omittedDetail = + failure.diagnosticBundle?.status === 'not_uploaded' + ? asSentence( + translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(failure.diagnosticBundle.reason) } + ) + ) + : null + const attachmentAlreadyOmitted = failure.diagnosticBundle?.status === 'not_uploaded' + const recovery = + includeDiagnosticLogs && !attachmentAlreadyOmitted + ? translate( + 'auto.components.crash.report.submit.notice.uncheckDiagnostics', + 'Uncheck "Attach recent diagnostic logs" and try again, or copy the details.' + ) + : translate( + 'auto.components.crash.report.submit.notice.checkConnection', + 'Check your connection and try again, or copy the details.' + ) + + return { + title: translate( + 'auto.components.crash.report.submit.notice.notSent', + "Crash report wasn't sent" + ), + description: [ + asSentence(normalizedFailureMessage(failure.error)), + ticketDetail, + omittedDetail, + recovery + ] + .filter((part): part is string => Boolean(part)) + .join(' '), + actionLabel: translate('auto.components.crash.report.submit.notice.copyDetails', 'Copy Details') + } +} + +export function getCrashReportSubmitWarningNotice( + result: Extract, + includeDiagnosticLogs: boolean +): CrashReportSubmitWarningNotice | null { + if (!includeDiagnosticLogs || result.diagnosticBundle?.status !== 'not_uploaded') { + return null + } + + return { + title: translate( + 'auto.components.crash.report.submit.notice.sentWithoutDiagnostics', + 'Crash report sent without diagnostic logs' + ), + description: translate( + 'auto.components.crash.report.submit.notice.diagnosticsReason', + 'Diagnostic logs were not attached: {{value0}}', + { value0: normalizedFailureMessage(result.diagnosticBundle.reason) } + ) + } +} diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx new file mode 100644 index 00000000000..69b010907ac --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' +import { CRASH_REPORT_COPY_FAILURE_TOAST_ID, useCrashReportCopy } from './use-crash-report-copy' + +const copyLatestDiagnostics = vi.fn() + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + success: vi.fn() + } +})) + +function report(id = 'crash-1'): CrashReportRecord { + return { + id, + createdAt: '2026-05-16T01:00:00.000Z', + status: 'pending', + source: 'renderer', + processType: 'renderer', + reason: 'crashed', + exitCode: 5, + appVersion: '1.0.0', + platform: 'win32', + osRelease: 'test', + arch: 'x64', + electronVersion: '41', + chromeVersion: '141', + details: {} + } +} + +beforeEach(() => { + vi.clearAllMocks() + copyLatestDiagnostics.mockResolvedValue({ ok: true }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { crashReports: { copyLatestDiagnostics } } + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useCrashReportCopy', () => { + it('uses the latest edited notes from an older failure-toast action', async () => { + const failure: CrashReportCopySubmissionFailure = { + error: 'report request timed out', + diagnosticContext: { status: 'not_uploaded', reason: 'logs timed out' } + } + const { result, rerender } = renderHook(({ notes }) => useCrashReportCopy(report(), notes), { + initialProps: { notes: 'notes at submit' } + }) + const failureToastCopyAction = result.current + + rerender({ notes: 'notes edited while waiting' }) + await act(async () => failureToastCopyAction(failure)) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-1', + notes: 'notes edited while waiting', + submissionFailure: failure + }) + }) + + it("does not combine an older report action with a newer report's notes", async () => { + const { result, rerender } = renderHook( + ({ currentReport, notes }) => useCrashReportCopy(currentReport, notes), + { + initialProps: { + currentReport: report('crash-a'), + notes: 'latest notes for A' + } + } + ) + const reportACopyAction = result.current + + rerender({ currentReport: report('crash-b'), notes: 'private notes for B' }) + await act(async () => reportACopyAction()) + + expect(copyLatestDiagnostics).toHaveBeenCalledWith({ + reportId: 'crash-a', + notes: 'latest notes for A' + }) + expect(JSON.stringify(copyLatestDiagnostics.mock.calls)).not.toContain('private notes for B') + }) + + it('keeps a returned copy failure visible with its safe reason', async () => { + copyLatestDiagnostics.mockResolvedValueOnce({ + ok: false, + error: 'Crash diagnostics are too large to copy safely.' + }) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + description: 'Crash diagnostics are too large to copy safely.', + duration: Infinity, + dismissible: true + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('shows a safe sticky error toast when copy IPC rejects', async () => { + copyLatestDiagnostics.mockRejectedValueOnce( + new Error('renderer destroyed with token=must-not-be-shown') + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes')) + + await act(async () => result.current()) + + expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + duration: Infinity, + dismissible: true + }) + expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('must-not-be-shown') + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/crash-report/use-crash-report-copy.ts b/src/renderer/src/components/crash-report/use-crash-report-copy.ts new file mode 100644 index 00000000000..b2a604a072c --- /dev/null +++ b/src/renderer/src/components/crash-report/use-crash-report-copy.ts @@ -0,0 +1,69 @@ +import { useCallback, useRef } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { + CrashReportCopySubmissionFailure, + CrashReportRecord +} from '../../../../shared/crash-reporting' + +export const CRASH_REPORT_COPY_FAILURE_TOAST_ID = 'crash-report-copy-failure' + +function showCopyFailure(description?: string): void { + toast.error( + translate( + 'auto.components.crash.report.copy.copyFailed', + 'Crash report details could not be copied.' + ), + { + id: CRASH_REPORT_COPY_FAILURE_TOAST_ID, + ...(description ? { description } : {}), + duration: Infinity, + dismissible: true + } + ) +} + +export function useCrashReportCopy( + report: CrashReportRecord | null, + notes: string +): (submissionFailure?: CrashReportCopySubmissionFailure) => Promise { + const reportId = report?.id ?? null + const notesRef = useRef({ reportId, value: notes }) + // Why: a submission toast can outlive the render that created it while the + // user edits or changes reports; keep live notes scoped to that report. + if (notesRef.current.reportId === reportId) { + notesRef.current.value = notes + } else { + notesRef.current = { reportId, value: notes } + } + const reportNotes = notesRef.current + + return useCallback( + async (submissionFailure?: CrashReportCopySubmissionFailure): Promise => { + try { + const result = await window.api.crashReports.copyLatestDiagnostics({ + ...(report ? { reportId: report.id } : {}), + notes: reportNotes.value, + ...(submissionFailure ? { submissionFailure } : {}) + }) + if (!result.ok) { + showCopyFailure(result.error) + return + } + toast.dismiss(CRASH_REPORT_COPY_FAILURE_TOAST_ID) + toast.success( + translate( + 'auto.components.crash.report.CrashReportDialog.8b8473c544', + 'Crash report copied.' + ) + ) + } catch (error) { + console.error('Failed to copy crash report details:', error) + // Why: Sonner closes an action toast when clicked, so a sticky generic + // replacement keeps the failure actionable without exposing raw IPC detail. + showCopyFailure() + } + }, + [report, reportNotes] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0a60a2d35c9..b920423d8a4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", "b082f27490": "Attach recent diagnostic logs", "e59f0b9427": "Sends a capped redacted log bundle with the report." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "Crash report details could not be copied." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b8c369477dd..64015250692 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No se capturó ningún informe de fallo automático. Aun así puedes enviar detalles e incluir logs de diagnóstico recientes cuando estén disponibles.", "b082f27490": "Adjuntar logs de diagnóstico recientes", "e59f0b9427": "Envía un paquete limitado de logs redactados junto con el informe." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "No se pudieron copiar los detalles del informe de fallo." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2796e629344..b7b02bf19eb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", "b082f27490": "Attach recent diagnostic logs", "e59f0b9427": "Sends a capped redacted log bundle with the report." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "クラッシュレポートの詳細をコピーできませんでした。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 9cd90833df5..5d24f4f3ca9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "자동 크래시 보고서가 캡처되지 않았습니다. 그래도 세부 정보를 보내고, 가능한 경우 최근 진단 로그를 포함할 수 있습니다.", "b082f27490": "최근 진단 로그 첨부", "e59f0b9427": "보고서와 함께 크기가 제한되고 민감 정보가 제거된 로그 묶음을 보냅니다." + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "충돌 보고서 세부 정보를 복사하지 못했습니다." } } }, @@ -12734,7 +12749,7 @@ "confirm": { "move": { "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", "action": "프로젝트 이동" }, "copy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ee6099601e6..8ef27b22171 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11905,6 +11905,21 @@ "ead6fc0510": "没有捕获到自动崩溃报告。你仍然可以发送详情,并在可用时包含最近的诊断日志。", "b082f27490": "附加最近的诊断日志", "e59f0b9427": "随报告发送经过删减且有大小限制的日志包。" + }, + "submit": { + "notice": { + "unknownError": "The crash report request failed before it returned a reason.", + "ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.", + "uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.", + "checkConnection": "Check your connection and try again, or copy the details.", + "notSent": "Crash report wasn't sent", + "copyDetails": "Copy Details", + "sentWithoutDiagnostics": "Crash report sent without diagnostic logs", + "diagnosticsReason": "Diagnostic logs were not attached: {{value0}}" + } + }, + "copy": { + "copyFailed": "无法复制崩溃报告详细信息。" } } }, diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index af9ef812ce5..b65c9a3c095 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -108,6 +108,19 @@ export type CrashReportSubmitResult = diagnosticBundle?: CrashReportDiagnosticBundle } +export type CrashReportCopySubmissionFailure = { + error: string + diagnosticContext?: + | { status: 'uploaded'; ticketId: string } + | { status: 'not_uploaded'; reason: string } +} + +export type CrashReportCopyDiagnosticsArgs = { + reportId?: string + notes?: string + submissionFailure?: CrashReportCopySubmissionFailure +} + const MAX_STRING_DETAIL_LENGTH = 240 const MAX_STACK_DETAIL_LENGTH = 4_000 const MAX_BREADCRUMB_NAME_LENGTH = 80 From 7708f8e7fe5bf445b6112efeade866bff128812e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:05:58 -0700 Subject: [PATCH 059/138] perf: avoid blocking base-ref resolution (#8182) * perf: avoid blocking base-ref resolution * fix(git): bound async default-base probes * fix(git): bound default-base probes during creation * test(git): cover bounded Windows and WSL probes --- .../git/repo-default-base-timeout.test.ts | 46 +++++++++++++++++++ src/main/git/repo.ts | 23 ++++++++-- src/main/ipc/worktree-remote.ts | 9 ++-- src/main/ipc/worktrees-windows.test.ts | 5 ++ src/main/ipc/worktrees.test.ts | 32 ++++++------- src/main/runtime/orca-runtime.test.ts | 31 ++++++++----- src/main/runtime/orca-runtime.ts | 12 ++--- src/main/worktree-create-base-prefetch.ts | 4 +- 8 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/main/git/repo-default-base-timeout.test.ts diff --git a/src/main/git/repo-default-base-timeout.test.ts b/src/main/git/repo-default-base-timeout.test.ts new file mode 100644 index 00000000000..b7c1518bc77 --- /dev/null +++ b/src/main/git/repo-default-base-timeout.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getBaseRefDefault } from './repo' + +describe('getBaseRefDefault async subprocess bounds', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('bounds every local probe and degrades a timeout to no default', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect(getBaseRefDefault('/repo')).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ cwd: '/repo', timeout: 15_000 }) + } + }) + + it('preserves the same timeout when routing probes through WSL', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) + + await expect( + getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBeNull() + + expect(gitExecFileAsyncMock).toHaveBeenCalled() + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ + cwd: '\\\\wsl.localhost\\Ubuntu\\repo', + timeout: 15_000, + wslDistro: 'Ubuntu' + }) + } + }) +}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index f649017cdeb..fa1c23ebf0d 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -15,6 +15,13 @@ type LocalGitExecOptions = { wslDistro?: string } +type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } @@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise hasGitRefViaExec(exec, ref)) } +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + async function getDefaultBaseRefAsync( path: string, options: LocalGitExecOptions = {} ): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, gitExecOptions(path, options)) - ) + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) } /** diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 3f50a5ce056..e0fd8ee8fbc 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -27,7 +27,11 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' +import { + getBranchConflictKind, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from '../git/repo' import { resolveLocalGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' @@ -1999,8 +2003,7 @@ export async function createLocalWorktree( const baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: () => - resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)), + resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), isBaseUsable: async (baseBranchCandidate) => { if (runtime) { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 7c6d0572da1..73d10495b1f 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -10,6 +10,7 @@ const { removeWorktreeMock, resolveLocalGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getBranchConflictKindMock, getPRForBranchMock, @@ -38,6 +39,7 @@ const { removeWorktreeMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), @@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ getDefaultBaseRef: getDefaultBaseRefMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getBranchConflictKind: getBranchConflictKindMock })) @@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { removeWorktreeMock.mockReset() resolveLocalGitUsernameMock.mockReset() getDefaultBaseRefMock.mockReset() + resolveDefaultBaseRefWithLocalGitMock.mockReset() resolveDefaultBaseRefViaExecMock.mockReset() getBranchConflictKindMock.mockReset() getPRForBranchMock.mockReset() @@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index ade839b50fb..db5bf3b1a4a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -28,7 +28,8 @@ const { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -77,7 +78,8 @@ const { removeWorktreeMock: vi.fn(), forceDeleteLocalBranchMock: vi.fn(), resolveLocalGitUsernameMock: vi.fn(), - getDefaultBaseRefMock: vi.fn(), + getBaseRefDefaultMock: vi.fn(), + resolveDefaultBaseRefWithLocalGitMock: vi.fn(), resolveDefaultBaseRefViaExecMock: vi.fn(), getDefaultRemoteMock: vi.fn(), getBranchConflictKindMock: vi.fn(), @@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../git/repo', () => ({ - getDefaultBaseRef: getDefaultBaseRefMock, + getBaseRefDefault: getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, getBranchConflictKind: getBranchConflictKindMock @@ -300,7 +303,8 @@ describe('registerWorktreeHandlers', () => { removeWorktreeMock, forceDeleteLocalBranchMock, resolveLocalGitUsernameMock, - getDefaultBaseRefMock, + getBaseRefDefaultMock, + resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, @@ -401,7 +405,8 @@ describe('registerWorktreeHandlers', () => { ]) store.getAllWorktreeLineage.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') - getDefaultBaseRefMock.mockReturnValue('origin/main') + getBaseRefDefaultMock.mockResolvedValue('origin/main') + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') getBranchConflictKindMock.mockResolvedValue(null) @@ -525,6 +530,7 @@ describe('registerWorktreeHandlers', () => { await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo') expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( '/workspace/repo', 'origin/master' @@ -2050,12 +2056,6 @@ describe('registerWorktreeHandlers', () => { it('routes local worktree creation through the selected WSL project runtime', async () => { mockSelectedWslProjectRuntime() - resolveDefaultBaseRefViaExecMock.mockImplementation( - async (exec: (args: string[]) => Promise<{ stdout: string }>) => { - await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - return 'origin/main' - } - ) listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', @@ -2087,10 +2087,10 @@ describe('registerWorktreeHandlers', () => { false, { wslDistro: 'Ubuntu' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } - ) + expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({ + cwd: '/workspace/repo', + wslDistro: 'Ubuntu' + }) expect(getBranchConflictKindMock).toHaveBeenCalledWith( '/workspace/repo', 'improve-dashboard', @@ -4980,7 +4980,7 @@ describe('registerWorktreeHandlers', () => { // no origin/main, no origin/master, and no local main/master), we must // fail loudly with a message that prompts the user to pick a base // branch, not hand a non-existent ref to `git worktree add`. - resolveDefaultBaseRefViaExecMock.mockResolvedValue(null) + resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null) store.getRepo.mockReturnValue({ id: 'repo-1', path: '/workspace/repo', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1f0fb590010..b8d9eee175f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -45,7 +45,7 @@ import { runHook, shouldRunSetupForCreate } from '../hooks' -import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo' +import { getBaseRefDefault, getBranchConflictKind } from '../git/repo' import type { OrchestrationDb } from './orchestration/db' import type { MessagePriority, MessageRow, MessageType } from './orchestration/types' import { @@ -521,16 +521,23 @@ vi.mock('../github/issues', async (importOriginal) => { } }) -// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a -// fallback base branch. Real resolution shells out to `git` against the -// test's fabricated repo path, which has no refs, so we stub it to a -// predictable 'origin/main'. The runtime no longer silently fabricates this -// default, so tests that want the legacy behavior must express it via the mock. +// Why: CLI worktree creation resolves a default against fabricated repo paths +// in these tests, so keep the async resolver deterministic. vi.mock('../git/repo', async (importOriginal) => { const actual = (await importOriginal()) as Record + const actualGetBaseRefDefault = actual.getBaseRefDefault as ( + path: string, + options?: { wslDistro?: string } + ) => Promise return { ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + // Why: fabricated local test repos need a deterministic default, while + // WSL coverage must still exercise the real async Git-options path. + getBaseRefDefault: vi + .fn() + .mockImplementation((path: string, options?: { wslDistro?: string }) => + options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main') + ), getBranchConflictKind: vi.fn().mockResolvedValue(null) } }) @@ -2857,7 +2864,7 @@ describe('OrcaRuntimeService', () => { suggestLocalBaseRefUpdate: true } ) - expect(getDefaultBaseRef).toHaveBeenCalled() + expect(getBaseRefDefault).toHaveBeenCalled() } finally { getReposSpy.mockRestore() gitSpy.mockRestore() @@ -5363,7 +5370,7 @@ describe('OrcaRuntimeService', () => { it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() - vi.mocked(getDefaultBaseRef).mockClear() + vi.mocked(getBaseRefDefault).mockClear() const remoteStore = { ...store, getRepos: () => [ @@ -5399,7 +5406,7 @@ describe('OrcaRuntimeService', () => { } expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') - expect(getDefaultBaseRef).not.toHaveBeenCalled() + expect(getBaseRefDefault).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() }) @@ -5465,7 +5472,7 @@ describe('OrcaRuntimeService', () => { }) expect(asyncGitSpy).toHaveBeenCalledWith( ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - wslGitOptions + { ...wslGitOptions, timeout: 15_000 } ) expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions) expect(asyncGitSpy).toHaveBeenCalledWith( @@ -26473,6 +26480,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(gitSpy).toHaveBeenCalledWith( @@ -27140,6 +27148,7 @@ describe('OrcaRuntimeService', () => { }) expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: TEST_REPO_PATH, + timeout: 15_000, wslDistro: 'Ubuntu' }) expect(getBranchConflictKind).toHaveBeenCalledWith( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e183aff463e..d14528cedfa 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -600,7 +600,6 @@ import type { } from '../../shared/github-project-types' import { getBaseRefDefault, - getDefaultBaseRef, getDefaultRemote, getBranchConflictKind, isGitRepo, @@ -611,6 +610,7 @@ import { parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, mergeBaseRefSearchResultGroups, @@ -14264,8 +14264,8 @@ export class OrcaRuntimeService { repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => hasLocalWorktreeGitOptions - ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) - : Promise.resolve(getDefaultBaseRef(repo.path)), + ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) + : getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, @@ -14296,10 +14296,8 @@ export class OrcaRuntimeService { } }) if (!baseBranch) { - // Why: getDefaultBaseRef returns null when no suitable ref exists. - // Don't fabricate 'origin/main' — passing it to addWorktree would - // produce an opaque git failure. Surface a clear error so the CLI - // caller can pick an explicit --base ref. + // Why: a null default means no suitable ref exists; fail clearly instead + // of handing Git a fabricated origin/main ref. throw new Error( 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 42add57be66..8653c6866eb 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/types' import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' -import { getDefaultBaseRef } from './git/repo' +import { getBaseRefDefault } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' @@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase( const resolvedBaseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path), + resolveDefaultBaseRef: () => getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( repo.path, From fd6805a29919703d8fd84ac2c966647d2a314297 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:13:15 -0700 Subject: [PATCH 060/138] Fix mobile terminal query reply authority (#8227) * Fix mobile terminal query reply authority * fix(terminal): harden mobile query reply handoffs * fix(terminal): exclude passive mobile query responders * fix(terminal): gate mobile query replies on host capability Older hosts strip terminal.send's inputKind (zod drops unknown keys), so a forwarded xterm reply would land as ordinary floor-taking shell input. Hosts now advertise terminal.query-reply-input.v1 via status.get and mobile drops replies unless the host advertises it (pre-fix behavior). Also documents the bounded desktop-to-mobile handoff double-reply residual. Co-authored-by: Orca * fix(terminal): advance snapshot seq across recovery snapshots The pending-overflow recovery loop trims buffered output against recovery.seq while query replay and boundary strips kept using the initial snapshot seq. Unreachable under today's control flow (no await separates the initial-overflow consume from the loop), but the stale seq would silently drop covered query replies if that ordering ever changes. Track the seq that actually covered the buffered chunks. Co-authored-by: Orca --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 145 ++++++++++++++ docs/reference/terminal-query-authority.md | 39 +++- .../app/h/[hostId]/session/[worktreeId].tsx | 23 +++ mobile/src/session/TerminalPaneView.tsx | 3 + mobile/src/terminal/TerminalWebView.tsx | 7 +- .../mobile-terminal-query-reply.test.ts | 81 ++++++++ .../terminal/mobile-terminal-query-reply.ts | 51 +++++ .../src/terminal/terminal-webview-contract.ts | 1 + mobile/src/terminal/terminal-webview-html.ts | 18 +- .../terminal-webview-query-reply-injected.ts | 44 ++++ ...rminal-webview-query-reply-routing.test.ts | 69 +++++++ .../terminal-webview-query-reply-routing.ts | 14 ++ .../terminal-webview-query-reply.test.ts | 126 ++++++++++++ src/main/runtime/mobile-presence-lock.test.ts | 44 ++++ src/main/runtime/orca-runtime.ts | 25 +++ src/main/runtime/rpc/methods/terminal.ts | 189 ++++++++++++++---- src/main/runtime/rpc/terminal-send.test.ts | 151 ++++++++++++++ .../rpc/terminal-subscribe-buffer.test.ts | 89 +++++++++ .../terminal-pane/pty-connection.test.ts | 11 + .../terminal-pane/pty-connection.ts | 36 ++-- src/shared/protocol-version.ts | 8 +- src/shared/terminal-reply-query-scan.test.ts | 41 ++++ src/shared/terminal-reply-query-scan.ts | 125 ++++++++++++ 23 files changed, 1279 insertions(+), 61 deletions(-) create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.test.ts create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.test.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply.test.ts create mode 100644 src/shared/terminal-reply-query-scan.test.ts create mode 100644 src/shared/terminal-reply-query-scan.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1a2bb248fd6..f7d8172cb5f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2736,6 +2736,151 @@ ], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-query.mobile-view-authority", + "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "mobile-runtime-rpc-contract", + "surfaces": [ + "mobile terminal WebView", + "terminal query authority", + "runtime RPC", + "mobile subscription replay", + "multi-mobile input floor" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8128", + "https://github.com/stablyai/orca/pull/8206" + ], + "invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.", + "oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.", + "commands": [ + "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "mobile/src/terminal/terminal-webview-query-reply-routing.test.ts", + "mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/mobile-presence-lock.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/terminal-reply-query-scan.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "assertions": [ + "snapshot replay emits no terminal-data message before the replay boundary", + "a live query after the boundary emits exactly one terminal-data message", + "a superseded terminal generation cannot regain reply authority", + "xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "only the elected mobile subscriber can write a claimed query reply", + "valid replies skip mobileTookFloor", + "ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "live terminal data and view authority register before asynchronous mobile fit", + "a query received during that await is retained and emitted after the snapshot" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "desktop xterm onData replies are dropped while mobile owns the terminal driver", + "desktop capability-handler replies outside onData honor the same mobile authority lock" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "mobile and runtime deterministic unit gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue." + }, + "promotionCriteria": [ + "Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.", + "Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.", + "Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "knownGaps": [ + "No live iOS or Android WebView run is registered.", + "No real SSH, WSL, daemon, or two-device provider-contract run is registered.", + "Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.", + "A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands." + ], + "demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use." + }, { "id": "terminal-runtime.mobile-stream-budget", "title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded", diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index e05b5768836..bb6b11fea8c 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time): stream (CLI `terminal.read`, automation observers) do not suppress — they also do not answer; that bounded no-reply case matches today's behavior. +Mobile streams preserve that singularity across snapshot startup and multiple +views. The mobile WebView suppresses `onData` during snapshot replay, then +enables replies at an explicit live-output boundary. Clearing the WebView drops +that replay queue and resumes live reply authority immediately. If a live query +arrived while the initial snapshot was being built and its output sequence is +covered by that snapshot, runtime RPC re-emits only the bounded query sequence +after the snapshot; ordinary covered output stays deduplicated. `terminal.send` +accepts the resulting `inputKind: query-reply` only from the earliest active +phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest +is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not +change reply ownership; passive desktop-mode watchers are excluded. Peer phones +are rejected, the next subscriber is promoted on unsubscribe, and desktop +parser and capability handlers stay silent until desktop retakes the driver. +Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile +client never forwards replies to a host that lacks it, because such hosts +strip `inputKind` and would treat the bytes as floor-taking shell input. +Residual: a desktop→mobile driver handoff has a bounded double-reply window +until the async driver-change event reaches the desktop renderer's cached +driver map (`isPtyLocked`). + Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes triggered by option mutations). ## Reply classes -| Class | Queries | Answer source | -| --- | --- | --- | -| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | -| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | -| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | -| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | -| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | -| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | +| Class | Queries | Answer source | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | ### View-attribute bridge @@ -237,7 +257,8 @@ any spawn-window drops). the documented ConPTY DA1 variant and the view-attribute parser handlers the headless core cannot serve. 6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. + subscriber is attached. When multiple desktop/mobile views coexist, the + terminal driver and server-side mobile election keep one reply writer. **Contract amendment** — `terminal-model-view-contract.md` invariant 6 is replaced by: diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 18a393cae28..1b63bf59b7a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -112,6 +112,8 @@ import { import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' +import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, @@ -2424,10 +2426,13 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.clear() }, [connState]) + const hostQueryReplyInputSupportedRef = useRef(false) + useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + hostQueryReplyInputSupportedRef.current = false return } let stale = false @@ -2444,11 +2449,16 @@ export default function SessionScreen() { setAgentSessionHistorySupported( status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true ) + // Why: hosts without this capability strip inputKind from terminal.send, + // so a forwarded xterm reply would become floor-stealing shell input. + hostQueryReplyInputSupportedRef.current = + status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) setAgentSessionHistorySupported(false) + hostQueryReplyInputSupportedRef.current = false } }) return () => { @@ -3531,6 +3541,18 @@ export default function SessionScreen() { [allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput] ) + const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => { + void sendMobileTerminalQueryReply({ + bytes, + client: clientRef.current, + clientId: deviceTokenRef.current, + connected: connStateRef.current === 'connected', + handle, + hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current, + subscribedTerminals: terminalUnsubsRef.current + }) + }, []) + async function handleClearTerminal(target: Terminal) { if (!client) { return @@ -4800,6 +4822,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalQueryReply={handleTerminalQueryReply} onTerminalTap={handleTerminalTap} onFileTap={handleFileTap} onOpenUrl={handleTerminalOpenUrl} diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index beb8118dabf..06174cd0378 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -23,6 +23,7 @@ type TerminalPaneViewProps = { onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalQueryReply: (handle: string, bytes: string) => void onTerminalTap: (handle: string) => void onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void onOpenUrl: (handle: string, url: string) => void @@ -44,6 +45,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -80,6 +82,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)} onTerminalTap={() => onTerminalTap(handle)} onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)} onOpenUrl={(url) => onOpenUrl(handle, url)} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 4b84e44081a..7eded2ef5ba 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,6 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' -import { WebView } from 'react-native-webview' -import type { WebViewMessageEvent } from 'react-native-webview' +import { WebView, type WebViewMessageEvent } from 'react-native-webview' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { @@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' +import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' type Props = TerminalWebViewProps @@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef(function } catch { return } + routeTerminalQueryReply(msg, onTerminalQueryReply) if (msg.type === 'web-ready') { confirmWebReady(true) @@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts new file mode 100644 index 00000000000..1b76a89574d --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply' + +function createClient() { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: '1', + ok: true, + result: { send: { accepted: true, bytesWritten: 6 } }, + _meta: { runtimeId: 'runtime-1' } + }) + } as unknown as Pick +} + +describe('sendMobileTerminalQueryReply', () => { + it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: true, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(true) + + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + }) + + it.each([ + ['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'], + ['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'], + ['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'], + ['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1'] + ])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes, + client, + clientId: 'mobile-1', + connected, + handle, + hostSupportsQueryReplyInput: true, + subscribedTerminals + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + // Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops + // unknown keys) and would treat the reply as floor-taking shell input. + it('does not send to a host that has not advertised query-reply input support', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: false, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/terminal/mobile-terminal-query-reply.ts b/mobile/src/terminal/mobile-terminal-query-reply.ts new file mode 100644 index 00000000000..4dd7ae7a8a5 --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.ts @@ -0,0 +1,51 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +type TerminalSubscriptionRegistry = { + has: (handle: string) => boolean +} + +type MobileTerminalQueryReplyOptions = { + bytes: string + client: Pick | null + clientId: string | null + connected: boolean + handle: string + hostSupportsQueryReplyInput: boolean + subscribedTerminals: TerminalSubscriptionRegistry +} + +export function sendMobileTerminalQueryReply({ + bytes, + client, + clientId, + connected, + handle, + hostSupportsQueryReplyInput, + subscribedTerminals +}: MobileTerminalQueryReplyOptions): Promise { + // Why: every subscribed mobile xterm suppresses main's responder, including + // hidden panes, so ownership follows the subscription rather than focus. + // Hosts without terminal.query-reply-input.v1 strip inputKind and would take + // reply bytes as floor-stealing shell input, so drop (pre-fix behavior). + if ( + !client || + !connected || + !hostSupportsQueryReplyInput || + !subscribedTerminals.has(handle) || + !isTerminalQueryReply(bytes) + ) { + return Promise.resolve(false) + } + + return client + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + inputKind: 'query-reply', + ...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {}) + }) + .then(isTerminalSendRpcAccepted, () => false) +} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 808dece2648..63c155b7a23 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -28,6 +28,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalQueryReply?: (bytes: string) => void onTerminalTap?: () => void // Tap landed on a detected file path; RN resolves + opens it. onFileTap?: (pathText: string, line: number | null, column: number | null) => void diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index fcd5ea9cafa..69b2e6f6b51 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' @@ -215,7 +216,7 @@ window.onerror = function(msg) { var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; + var term = null; ${TERMINAL_QUERY_REPLY_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS} writeQueue.push(normalizeStatusDotPresentation(data)); } + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { resetWriteQueue(); @@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} if (!ready || !term || writesDraining || gen !== terminalGeneration) return; var next = nextQueuedWrite(); if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); var callbacks = afterDrainCallbacks; afterDrainCallbacks = []; for (var i = 0; i < callbacks.length; i++) callbacks[i](); @@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; terminalGeneration++; var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); cancelWebglContextRecovery(); webglAddon = null; ready = false; @@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} fontWeight: '300', fontWeightBold: '500', scrollback: 5000, - disableStdin: true, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, cursorBlink: false, cursorStyle: 'bar', cursorInactiveStyle: 'none', @@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} resetEvictionCounter(); cancelSelect(); attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); requestAnimationFrame(function() { if (gen !== terminalGeneration) return; @@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} write(msg.data); } else if (msg.type === 'clear') { terminalGeneration++; - resetWriteQueue(); + resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. statusDotPendingSelector = false; afterDrainCallbacks = []; writesDraining = false; diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts new file mode 100644 index 00000000000..a4ac34e5809 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-injected.ts @@ -0,0 +1,44 @@ +// Kept as one injectable unit so tests execute the same replay/generation gate +// that the WebView document runs, rather than a TypeScript reimplementation. +export const TERMINAL_QUERY_REPLY_JS = ` + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } +` diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts new file mode 100644 index 00000000000..af53772d481 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, _ref) => + React.createElement('WebView', props) + ) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +let renderer: ReactTestRenderer | null = null + +function postWebViewMessage(payload: Record): void { + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +describe('TerminalWebView query reply routing', () => { + afterEach(() => { + if (renderer) { + act(() => renderer?.unmount()) + renderer = null + } + vi.restoreAllMocks() + }) + + it('routes only complete terminal query replies to native', () => { + const onTerminalQueryReply = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) + }) + + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) + postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) + + expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) + expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.ts new file mode 100644 index 00000000000..cf79fb71b7a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.ts @@ -0,0 +1,14 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' + +export function routeTerminalQueryReply( + message: Record, + onTerminalQueryReply: ((bytes: string) => void) | undefined +): void { + if ( + message.type === 'terminal-data' && + typeof message.bytes === 'string' && + isTerminalQueryReply(message.bytes) + ) { + onTerminalQueryReply?.(message.bytes) + } +} diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts new file mode 100644 index 00000000000..4e8add33f2a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +type QueryReplyGate = { + forward: (data: string) => void + queueBoundary: (generation: number) => void + reset: () => void + resume: () => void + setGeneration: (generation: number) => void +} + +function createQueryReplyGate(notify: (message: unknown) => void): { + gate: QueryReplyGate + queuedBoundaries: Array<() => void> +} { + const queuedBoundaries: Array<() => void> = [] + const factory = new Function( + 'notify', + 'enqueueWriteBoundary', + `var terminalGeneration = 0; + ${TERMINAL_QUERY_REPLY_JS} + return { + forward: forwardTerminalDataReply, + queueBoundary: enqueueTerminalDataReplyBoundary, + reset: resetTerminalDataReplyAuthority, + resume: resumeTerminalDataReplyAuthority, + setGeneration: function(next) { terminalGeneration = next; } + };` + ) as ( + notify: (message: unknown) => void, + enqueueBoundary: (callback: () => void) => void + ) => QueryReplyGate + const gate = factory(notify, (callback) => queuedBoundaries.push(callback)) + return { gate, queuedBoundaries } +} + +describe('mobile terminal query replies', () => { + it('forwards xterm-generated data only after initial replay drains', () => { + const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + listenerIndex + ) + const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'forwardTerminalDataReply(data)', + listenerIndex + ) + + expect(listenerIndex).toBeGreaterThan(-1) + expect(enableIndex).toBeGreaterThan(listenerIndex) + expect(notifyIndex).toBeGreaterThan(listenerIndex) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') + expect(XTERM_WEBVIEW_SOURCE.html).toContain( + 'term.attachCustomKeyEventHandler(function() { return false; })' + ) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + }) + + it('mutes a replacement terminal until its own replay drains', () => { + const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData') + const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'resetTerminalDataReplyAuthority()', + initIndex + ) + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + disableIndex + ) + + expect(initIndex).toBeGreaterThan(-1) + expect(disableIndex).toBeGreaterThan(initIndex) + expect(enableIndex).toBeGreaterThan(disableIndex) + }) + + it('suppresses replay, then forwards live queries queued behind its boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + + queuedBoundaries[0]?.() + gate.forward('\x1b[2;2R') + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }]) + }) + + it('does not let a superseded generation reclaim reply authority', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.queueBoundary(1) + gate.setGeneration(2) + gate.reset() + + queuedBoundaries[0]?.() + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + }) + + it('restores reply authority when clear discards the replay boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + // A clear drops all queued writes, including the replay boundary. + queuedBoundaries.length = 0 + gate.resume() + gate.forward('\x1b[3;4R') + + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) + const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {") + const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf( + "} else if (msg.type === 'measure')", + clearStart + ) + expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain( + 'resumeTerminalDataReplyAuthority()' + ) + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eddb3f299cf..6213765bb20 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + it('elects one query responder and promotes the survivor on unsubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + + it('excludes an older passive desktop-mode subscriber from reply authority', async () => { + const { runtime } = createRuntime() + runtime.setMobileDisplayMode('pty-1', 'desktop') + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + await vi.advanceTimersByTimeAsync(10) + runtime.markMobileActor('pty-1', 'phone-B') + runtime.setMobileDisplayMode('pty-1', 'auto') + await runtime.applyMobileDisplayMode('pty-1') + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + }) + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d14528cedfa..2ef3dfb0e10 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6476,6 +6476,31 @@ export class OrcaRuntimeService { return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 } + isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { + // Why: a passive phone watching desktop-sized output must not race the + // desktop xterm. Mobile becomes reply authority only with the mobile floor. + if (this.getDriver(ptyId).kind !== 'mobile') { + return false + } + const subscribers = this.mobileSubscribers.get(ptyId) + if (!subscribers) { + return false + } + // Why: soft-leave resubscribe preserves the original subscription time but + // reinserts the record. Elect fitted responders from that stable age, not + // mutable Map order or passive desktop-mode watchers. + let earliest: { clientId: string; subscribedAt: number } | null = null + for (const subscriber of subscribers.values()) { + if (!subscriber.wasResizedToPhone) { + continue + } + if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { + earliest = subscriber + } + } + return earliest?.clientId === clientId + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 73c77701c97..339ae8751a9 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -26,6 +26,13 @@ import { } from '../../../../shared/terminal-input' import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQuerySequence, + type TerminalReplyQueryScanState +} from '../../../../shared/terminal-reply-query-scan' import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS @@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -372,6 +380,29 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + function appendAckPendingOutput( stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk @@ -699,6 +730,9 @@ const TerminalSend = TerminalHandle.extend({ enter: z.unknown().optional(), interrupt: z.unknown().optional(), requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input bytes but are not a user + // action that should transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), // Why: identifies the caller for the driver state machine. Optional for // backward compatibility with older mobile clients (server falls back to // the most recent mobile actor when absent). New mobile builds populate @@ -983,13 +1017,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } // Why: guarded resolution — a stale handle must fail with // terminal_handle_stale (clients recover by re-deriving the handle) // instead of evaluating driver/lock state against the wrong PTY (#7718). const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { @@ -1096,7 +1157,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (leaf?.ptyId && mobileFloorClientId) { + if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } @@ -2134,6 +2195,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -2236,6 +2301,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ).catch(() => {}) } }) ?? (() => {}) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: live bytes must be captured before mobile fit awaits. Registering + // mobile presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { @@ -2245,36 +2361,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: binary subscribe streams feed remote xterm views (mobile and - // binary-capable desktop clients) that answer queries with view - // authority; the main model responder yields while attached. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { @@ -2305,7 +2391,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq const snapshotFrameSeq = serialized?.seq ?? layoutSeq - const snapshotOutputSeq = serialized?.seq + // Why: recovery snapshots advance output coverage past the initial + // snapshot seq; query replay and boundary trims must track the seq + // that actually covered the buffered chunks or a query absorbed by a + // recovery snapshot gets zero replies. + let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', streamId, @@ -2392,12 +2482,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) pendingOutput = trimmed.chunks pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq } buffering = false const bufferedOutput = pendingOutput.splice(0) + const queryReplayData = pendingQueryOverflowed + ? '' + : pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: serialized snapshots omit control queries, yet their output seq + // can trim the live chunk. Replay only the query after snapshot so the + // mobile xterm answers once while ordinary output stays deduplicated. + outputBatcher.push(queryReplayData) + } if (!initialOutputOverflowed) { for (const item of bufferedOutput) { - const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + pendingQuerySequences + ) + } if (uncoveredData) { outputBatcher.push(uncoveredData, item.meta) } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index c426513c9f5..243003eeec3 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -4,6 +4,10 @@ import type { RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import { TERMINAL_METHODS } from './methods/terminal' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' +import { + RUNTIME_CAPABILITIES, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR @@ -169,6 +173,148 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') }) + it('writes a validated terminal query reply without taking the mobile floor', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: '\x1b[3;4R', enter: false, interrupt: false }, + { beforeWrite: undefined } + ) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts a query reply from only the elected mobile subscriber', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + isMobileTerminalQueryReplyAuthority: vi.fn( + (_ptyId: string, clientId: string) => clientId === 'mobile-1' + ), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const makeReply = (clientId: string) => + dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: clientId, type: 'mobile' } + }) + ) + const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')]) + + expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } }) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(1) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('rejects ordinary input that claims to be a terminal query reply', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'a', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it.each([ + ['enter', { enter: true }], + ['interrupt', { interrupt: true }], + ['agent guard', { requireAgentStatus: 'sendable' }] + ])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' }, + ...extra + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('rejects query replies that spoof a different authenticated mobile client', async () => { + const replies: string[] = [] + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'spoofed-mobile', type: 'mobile' } + }), + (reply) => replies.push(reply), + { clientId: 'authenticated-mobile' } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('rejects oversized terminal send text before runtime dispatch', async () => { const secret = 'terminal-send-secret' const runtime = stubRuntime({ @@ -435,4 +581,9 @@ describe('terminal send RPC', () => { expect(response.result).toEqual({ restored: true }) expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') }) + // Why: mobile gates reply forwarding on this static capability; removing it + // would silently re-break mobile query answering against current hosts. + it('advertises query-reply input support to mobile clients', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) + }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index ba921c6b14e..06b006a4d0e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => { } }) + it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveMobileSubscribe: () => void = () => {} + const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn()) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve) => { + resolveMobileSubscribe = () => resolve(true) + }) + ), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(dataListener).toBeDefined() + expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1') + dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 }) + resolveMobileSubscribe() + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + ).toBe(true) + ) + const queryOutputFrames = binaryFrames.filter((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + expect(queryOutputFrames).toHaveLength(1) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const runtime = stubRuntime({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 18fbf3e1f6a..081b22be937 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4286,10 +4286,21 @@ describe('connectPanePty', () => { } // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') + // Capability handlers are registered outside onData and must honor the + // same mobile query-authority lock. + const csiCalls = ( + pane.terminal.parser.registerCsiHandler as unknown as { + mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] } + } + ).mock.calls + const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1] + expect(da1Handler).toBeTypeOf('function') + da1Handler?.([]) await flushAsyncTicks() expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0578868f48b..9e313fa50bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3254,6 +3254,14 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + const canSendDesktopQueryReply = (): boolean => { + const ptyId = transport.getPtyId() + return !ptyId || !isPtyLocked(ptyId) + } + // Why: parser/capability handlers bypass the ordinary onData guard. Keep + // desktop silent while the elected mobile xterm owns query replies. + const sendDesktopQueryReplyImmediate = (data: string): boolean => + canSendDesktopQueryReply() && transport.sendInputImmediate(data) // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 // responder — visible, hidden, marked or not. Conditioning the reply on the // hidden mark double-fired (mark set + bytes delivered live via interest → @@ -3271,7 +3279,7 @@ export function connectPanePty( ) // Why immediate: a mode-2031 query reply must beat the remote input debounce // or it can miss the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) // Why: register the subscription exactly like the xterm CSI handler // would — without the registry entry, later theme flips never push the // CSI 997 update and the TUI keeps a stale theme after reveal. @@ -3285,13 +3293,13 @@ export function connectPanePty( // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode // read window; the remote transport's input debounce would corrupt them // (#7329), so send immediately. - sendInput: (data) => transport.sendInputImmediate(data), + sendInput: sendDesktopQueryReplyImmediate, isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( pane.terminal, - (data) => transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate ) const onDataDisposable = pane.terminal.onData((data) => { @@ -3339,7 +3347,7 @@ export function connectPanePty( // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), // so a real keystroke never reaches this branch. if (isTerminalQueryReply(data)) { - transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate(data) return } const intent = pendingTerminalInputIntent @@ -5031,7 +5039,7 @@ export function connectPanePty( // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer // immediately so the reply cannot outlive the program's read window. deps.paneMode2031Ref.current.set(pane.id, true) - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) deps.paneLastThemeModeRef.current.set(pane.id, mode) recordHiddenMode2031Reply() } @@ -5202,8 +5210,10 @@ export function connectPanePty( // Why: Codex's startup palette probe has a 100 ms budget. Answer // hidden color queries directly and immediately so neither renderer // scheduling nor the remote input debounce (#7329) can miss it. - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } if (extracted.statelessQueryData) { @@ -5322,8 +5332,10 @@ export function connectPanePty( } const extracted = extractHiddenStartupRendererQueryData(data, '') if (extracted.oscColorQueryData) { - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } let unansweredQueryData = '' @@ -5337,9 +5349,9 @@ export function connectPanePty( const buffer = pane.terminal.buffer.active const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) - transport.sendInputImmediate(`\x1b[${row};${col}R`) + sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { - transport.sendInputImmediate(DEFAULT_DA1_RESPONSE) + sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE) } else { unansweredQueryData += sequence } @@ -6293,7 +6305,7 @@ export function connectPanePty( pane.terminal, // Why: OSC color reply — immediate so the remote debounce cannot delay // it past the querying program's read window (#7329). - (reply) => transport.sendInputImmediate(reply) + sendDesktopQueryReplyImmediate ) } const restoreAppliesToCurrentPty = diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 68a693ab42b..89e4303014c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -38,6 +38,11 @@ export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +// Why: hosts without this strip terminal.send's inputKind (zod object drops +// unknown keys), so a mobile xterm query reply would land as ordinary +// floor-taking input. Mobile must not forward replies unless advertised. +export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = + 'terminal.query-reply-input.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -53,7 +58,8 @@ export const RUNTIME_CAPABILITIES = [ WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, - AI_VAULT_RUNTIME_CAPABILITY + AI_VAULT_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/terminal-reply-query-scan.test.ts b/src/shared/terminal-reply-query-scan.test.ts new file mode 100644 index 00000000000..2a94eaad63d --- /dev/null +++ b/src/shared/terminal-reply-query-scan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences +} from './terminal-reply-query-scan' + +describe('terminal reply query scan', () => { + it('records reply-eliciting queries with their output high-water sequence', () => { + const data = `before\x1b[6nafter\x1b[?2031h` + const result = scanTerminalReplyQuerySequences(data, 100, EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + + expect(result.queries).toEqual([ + { data: '\x1b[6n', startSeq: 106, endSeq: 110 }, + { data: '\x1b[?2031h', startSeq: 115, endSeq: 123 } + ]) + expect(result.state).toEqual(EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + }) + + it('assembles a query split across contiguous PTY chunks', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 23, first.state) + + expect(first.queries).toEqual([]) + expect(second.queries).toEqual([{ data: '\x1b[?2026$p', startSeq: 20, endSeq: 29 }]) + }) + + it('drops a partial query when output sequence continuity is lost', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 30, first.state) + + expect(second.queries).toEqual([]) + }) +}) diff --git a/src/shared/terminal-reply-query-scan.ts b/src/shared/terminal-reply-query-scan.ts new file mode 100644 index 00000000000..434d37041d7 --- /dev/null +++ b/src/shared/terminal-reply-query-scan.ts @@ -0,0 +1,125 @@ +import { findCsiFinalByteIndex } from './terminal-reply-query-extraction' +import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +const ESC = '\x1b' +const MAX_PENDING_QUERY_CHARS = 4096 +/* oxlint-disable no-control-regex -- terminal query grammars contain ESC by definition */ +const DEVICE_ATTRIBUTES_QUERY_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') +const MODE_QUERY_RE = new RegExp('^\\u001b\\[\\??[0-9;]+\\$p$') +/* oxlint-enable no-control-regex */ + +export type TerminalReplyQuerySequence = { + data: string + startSeq: number + endSeq: number +} + +export type TerminalReplyQueryScanState = { + pending: string + pendingStartSeq: number | null +} + +export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState = { + pending: '', + pendingStartSeq: null +} + +function isReplyElicitingCsi(sequence: string): boolean { + if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) { + return true + } + if (MODE_QUERY_RE.test(sequence)) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[6n' || + sequence === '\x1b[?6n' || + sequence === '\x1b[?996n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' || + sequence === '\x1b[18t' || + sequence === '\x1b[?u' || + sequence === '\x1b[?2031h' + ) +} + +function boundedPending(input: string, startIndex: number): string { + return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS) +} + +export function scanTerminalReplyQuerySequences( + data: string, + chunkStartSeq: number, + previous: TerminalReplyQueryScanState +): { queries: TerminalReplyQuerySequence[]; state: TerminalReplyQueryScanState } { + const continuesPending = + previous.pendingStartSeq !== null && + previous.pendingStartSeq + previous.pending.length === chunkStartSeq + const pending = continuesPending ? previous.pending : '' + const input = pending + data + const inputStartSeq = chunkStartSeq - pending.length + const queries: TerminalReplyQuerySequence[] = [] + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf(ESC, offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + + let endIndex = -1 + let matches = false + if (input.startsWith(`${ESC}[`, candidateIndex)) { + endIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (endIndex !== -1) { + matches = isReplyElicitingCsi(input.slice(candidateIndex, endIndex + 1)) + } + } else if (input.startsWith(`${ESC}]`, candidateIndex)) { + const osc = parseTerminalOscColorQuery(input, candidateIndex) + if (osc.kind === 'partial') { + endIndex = -1 + } else if (osc.kind === 'match') { + endIndex = osc.endIndex - 1 + matches = true + } else { + endIndex = candidateIndex + 1 + } + } else if (input.startsWith(`${ESC}P`, candidateIndex)) { + const terminatorIndex = input.indexOf(`${ESC}\\`, candidateIndex + 2) + if (terminatorIndex !== -1) { + endIndex = terminatorIndex + 1 + const body = input.slice(candidateIndex + 2, terminatorIndex) + matches = body.startsWith('$q') || body.startsWith('+q') + } + } else { + endIndex = candidateIndex + } + + if (endIndex === -1) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + if (matches) { + queries.push({ + data: input.slice(candidateIndex, endIndex + 1), + startSeq: inputStartSeq + candidateIndex, + endSeq: inputStartSeq + endIndex + 1 + }) + } + offset = endIndex + 1 + } + + return { queries, state: EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE } +} From cb6a5e4cb888a65694c69c1ac420c0a972dd65f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:20:08 -0700 Subject: [PATCH 061/138] perf(renderer): dedupe concurrent Markdown document scans (#8115) * perf(renderer): dedupe concurrent Markdown document scans * fix(renderer): preserve Markdown refresh freshness * fix(renderer): bound shared Markdown scan joins * fix(renderer): evict abandoned Markdown scan entries Co-authored-by: Orca --------- Co-authored-by: Orca --- .../markdown-document-list-request.test.ts | 176 ++++++++++++++++++ .../editor/markdown-document-list-request.ts | 69 +++++++ .../components/editor/useMarkdownDocuments.ts | 75 ++++---- 3 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-document-list-request.test.ts create mode 100644 src/renderer/src/components/editor/markdown-document-list-request.ts diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts new file mode 100644 index 00000000000..085b8599fb8 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { + getMarkdownDocumentListRequestKey, + requestSharedMarkdownDocumentList +} from './markdown-document-list-request' + +function context(overrides: Partial = {}): RuntimeFileOperationArgs { + return { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'worktree-1', + worktreePath: '/repo', + ...overrides + } +} + +function deferred(): { + promise: Promise + reject: (error: Error) => void + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('shared Markdown document list requests', () => { + it('collapses concurrent pane scans and releases the result after settlement', async () => { + const pending = deferred() + const load = vi.fn(() => pending.promise) + const requests = Array.from({ length: 200 }, () => + requestSharedMarkdownDocumentList(context(), '/repo', {}, load) + ) + + expect(load).toHaveBeenCalledTimes(1) + expect(new Set(requests).size).toBe(1) + + const documents = [{ filePath: '/repo/README.md', relativePath: 'README.md' }] + pending.resolve(documents as MarkdownDocument[]) + await expect(Promise.all(requests)).resolves.toEqual( + Array.from({ length: 200 }, () => documents) + ) + + const nextDocuments = [{ filePath: '/repo/notes.md', relativePath: 'notes.md' }] + load.mockResolvedValueOnce(nextDocuments as MarkdownDocument[]) + await expect(requestSharedMarkdownDocumentList(context(), '/repo', {}, load)).resolves.toBe( + nextDocuments + ) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('starts a fresh scan after a mutation instead of joining an older snapshot', async () => { + const stale = deferred() + const fresh = deferred() + const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) + + const initialRequest = requestSharedMarkdownDocumentList(context(), '/repo', {}, load) + const mutationRefresh = requestSharedMarkdownDocumentList( + context(), + '/repo', + { requireFresh: true }, + load + ) + + expect(load).toHaveBeenCalledTimes(2) + expect(mutationRefresh).not.toBe(initialRequest) + + const freshDocuments = [{ filePath: '/repo/new.md', relativePath: 'new.md' }] + fresh.resolve(freshDocuments as MarkdownDocument[]) + await expect(mutationRefresh).resolves.toBe(freshDocuments) + + const staleDocuments = [{ filePath: '/repo/old.md', relativePath: 'old.md' }] + stale.resolve(staleDocuments as MarkdownDocument[]) + await expect(initialRequest).resolves.toBe(staleDocuments) + }) + + it('allows an ordinary retry after an older scan stops settling', async () => { + const stale = deferred() + const retry = deferred() + const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(retry.promise) + const now = vi.spyOn(performance, 'now').mockReturnValue(1_000) + + try { + const initialRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load) + now.mockReturnValue(31_001) + const retryRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load) + + expect(load).toHaveBeenCalledTimes(2) + expect(retryRequest).not.toBe(initialRequest) + + retry.resolve([]) + stale.resolve([]) + await expect(retryRequest).resolves.toEqual([]) + await expect(initialRequest).resolves.toEqual([]) + } finally { + now.mockRestore() + } + }) + + it('evicts an abandoned scan for a route that is never requested again', async () => { + const abandoned = deferred() + const load = vi.fn().mockReturnValueOnce(abandoned.promise).mockResolvedValueOnce([]) + const now = vi.spyOn(performance, 'now').mockReturnValue(1_000) + + try { + const abandonedRequest = requestSharedMarkdownDocumentList( + context(), + '/abandoned-only', + {}, + load + ) + now.mockReturnValue(31_001) + // A request for a different route sweeps the expired entry so the map + // does not pin the abandoned promise for the renderer's lifetime. + await expect( + requestSharedMarkdownDocumentList(context(), '/other-route', {}, load) + ).resolves.toEqual([]) + + load.mockResolvedValueOnce([]) + await expect( + requestSharedMarkdownDocumentList(context(), '/abandoned-only', {}, load) + ).resolves.toEqual([]) + expect(load).toHaveBeenCalledTimes(3) + + abandoned.resolve([]) + await expect(abandonedRequest).resolves.toEqual([]) + } finally { + now.mockRestore() + } + }) + + it('keeps runtime routes isolated and encodes unusual paths losslessly', () => { + const local = context() + const ssh = context({ connectionId: 'ssh-1' }) + const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } }) + const unusualRoot = '/repo\nwith-newline' + const windowsRoot = 'C:\\repo\\docs' + const uncRoot = '\\\\server\\share\\repo' + + expect(getMarkdownDocumentListRequestKey(local, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(ssh, unusualRoot) + ) + expect(getMarkdownDocumentListRequestKey(ssh, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(runtime, unusualRoot) + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, unusualRoot)).at(-1)).toBe( + unusualRoot + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, windowsRoot)).at(-1)).toBe( + windowsRoot + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, uncRoot)).at(-1)).toBe(uncRoot) + }) + + it('clears rejected requests so a retry can run', async () => { + const failure = deferred() + const load = vi.fn(() => failure.promise) + const first = requestSharedMarkdownDocumentList(context(), '/retry', {}, load) + const second = requestSharedMarkdownDocumentList(context(), '/retry', {}, load) + + failure.reject(new Error('offline')) + await expect(first).rejects.toThrow('offline') + await expect(second).rejects.toThrow('offline') + + load.mockResolvedValueOnce([]) + await expect(requestSharedMarkdownDocumentList(context(), '/retry', {}, load)).resolves.toEqual( + [] + ) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts new file mode 100644 index 00000000000..7e9cc0f32b3 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -0,0 +1,69 @@ +import type { MarkdownDocument } from '../../../../shared/types' +import { + listRuntimeMarkdownDocuments, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' + +type MarkdownDocumentListLoader = ( + context: RuntimeFileOperationArgs, + rootPath: string +) => Promise + +type MarkdownDocumentListRequestOptions = { + requireFresh?: boolean +} + +type InFlightMarkdownDocumentList = { + request: Promise + startedAt: number +} + +const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000 +const inFlightMarkdownDocumentLists = new Map() + +export function getMarkdownDocumentListRequestKey( + context: RuntimeFileOperationArgs, + rootPath: string +): string { + return JSON.stringify([ + context.settings?.activeRuntimeEnvironmentId?.trim() ?? '', + context.connectionId ?? '', + context.worktreeId ?? '', + context.worktreePath ?? '', + rootPath + ]) +} + +export function requestSharedMarkdownDocumentList( + context: RuntimeFileOperationArgs, + rootPath: string, + options: MarkdownDocumentListRequestOptions = {}, + load: MarkdownDocumentListLoader = listRuntimeMarkdownDocuments +): Promise { + const now = performance.now() + // Why: a never-settling scan for a route that is never requested again would + // otherwise pin its map entry (and captured context) for the renderer's life. + for (const [staleKey, entry] of inFlightMarkdownDocumentLists) { + if (now - entry.startedAt >= MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS) { + inFlightMarkdownDocumentLists.delete(staleKey) + } + } + + const key = getMarkdownDocumentListRequestKey(context, rootPath) + const existing = inFlightMarkdownDocumentLists.get(key) + if (existing && !options.requireFresh) { + return existing.request + } + + // Why: split Markdown panes mount together and otherwise launch identical + // whole-worktree local/SSH scans; mutation refreshes bypass older snapshots. + const request = load(context, rootPath).finally(() => { + if (inFlightMarkdownDocumentLists.get(key)?.request === request) { + inFlightMarkdownDocumentLists.delete(key) + } + }) + // Why: local and UNC filesystem calls have no timeout, so an abandoned scan + // must not suppress every ordinary retry for the renderer's lifetime. + inFlightMarkdownDocumentLists.set(key, { request, startedAt: now }) + return request +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 1dd3c22c6fd..ae7fca13de6 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' -import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { statRuntimePath } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { @@ -11,6 +11,7 @@ import { resolveMarkdownDocLink } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' +import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -52,43 +53,47 @@ export function useMarkdownDocuments( const connectionId = getConnectionId(worktreeId) - const refreshMarkdownDocuments = useCallback(async (): Promise => { - if (!worktreeId || !worktreePath) { - return - } - - const requestId = requestRef.current + 1 - requestRef.current = requestId - try { - const documents = await listRuntimeMarkdownDocuments( - { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, - activeFile.runtimeEnvironmentId - ), - worktreeId, - worktreePath, - connectionId: connectionId ?? undefined - }, - worktreePath - ) - if (requestRef.current !== requestId) { + const refreshMarkdownDocuments = useCallback( + async (requireFresh = false): Promise => { + if (!worktreeId || !worktreePath) { return } - setMarkdownDocumentsByWorktree((prev) => ({ - ...prev, - [worktreeId]: documents - })) - } catch (err) { - console.error('Failed to list markdown documents:', err) - if (requestRef.current === requestId) { + + const requestId = requestRef.current + 1 + requestRef.current = requestId + try { + const documents = await requestSharedMarkdownDocumentList( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + worktreePath, + { requireFresh } + ) + if (requestRef.current !== requestId) { + return + } setMarkdownDocumentsByWorktree((prev) => ({ ...prev, - [worktreeId]: [] + [worktreeId]: documents })) + } catch (err) { + console.error('Failed to list markdown documents:', err) + if (requestRef.current === requestId) { + setMarkdownDocumentsByWorktree((prev) => ({ + ...prev, + [worktreeId]: [] + })) + } } - } - }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]) + }, + [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath] + ) const openMarkdownDocument = useCallback( async ( @@ -112,11 +117,11 @@ export function useMarkdownDocuments( document.filePath ) if (stats.isDirectory) { - await refreshMarkdownDocuments() + await refreshMarkdownDocuments(true) return } } catch { - await refreshMarkdownDocuments() + await refreshMarkdownDocuments(true) return } @@ -174,7 +179,7 @@ export function useMarkdownDocuments( ) const mdSave = useCallback( - (content: string) => onSave(content).then(() => refreshMarkdownDocuments()), + (content: string) => onSave(content).then(() => refreshMarkdownDocuments(true)), [onSave, refreshMarkdownDocuments] ) From fb2f25e8f6c92d916a1472ec611d87e4d96485c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:21:28 -0700 Subject: [PATCH 062/138] Update mobile 0.0.27 Android download links (#8257) --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.pt.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 136b04ef090..095b0f486e9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 749d07b5811..f8d168e5f62 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b19a58f5012..6c5e1bbfff5 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 9848afd6ef5..ce9a13cc30b 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 5dd3eee6274..d81c6077879 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 7fe73c4b074..c771506ed7f 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index 1c9e3f41e61..4499e1594cf 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 76c025220df..d077cbea902 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From 674ab4956700b981bf111f75774929560822199c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:57:17 -0700 Subject: [PATCH 063/138] Bound simulator prelaunch handoff cache --- .../src/lib/open-mobile-emulator-tab.test.ts | 5 +- .../lib/simulator-launch-coordination.test.ts | 110 ++++++++++++++++++ .../src/lib/simulator-launch-coordination.ts | 48 +++++++- 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/simulator-launch-coordination.test.ts diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index 100e0532ef7..3def4ca88fb 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -4,7 +4,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { openMobileEmulatorTab } from './open-mobile-emulator-tab' import { consumePrelaunchedSimulatorSession, - isManualSimulatorLaunchPending + isManualSimulatorLaunchPending, + resetSimulatorLaunchCoordinationForTests } from './simulator-launch-coordination' import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler' import { ensureSimulatorTab, getSimulatorTabForWorktree } from './ensure-simulator-tab' @@ -62,7 +63,7 @@ describe('openMobileEmulatorTab', () => { vi.mocked(getSimulatorTabForWorktree).mockReset() vi.mocked(getSimulatorTabForWorktree).mockReturnValue(null) vi.mocked(toast.error).mockReset() - consumePrelaunchedSimulatorSession('wt-1') + resetSimulatorLaunchCoordinationForTests() }) afterEach(() => { diff --git a/src/renderer/src/lib/simulator-launch-coordination.test.ts b/src/renderer/src/lib/simulator-launch-coordination.test.ts new file mode 100644 index 00000000000..a8d70f5ac0c --- /dev/null +++ b/src/renderer/src/lib/simulator-launch-coordination.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EmulatorStreamInfo } from '@/components/emulator-pane/emulator-pane-types' +import { + consumePrelaunchedSimulatorSession, + getPrelaunchedSimulatorSessionCountForTests, + rememberPrelaunchedSimulatorSession, + resetSimulatorLaunchCoordinationForTests +} from './simulator-launch-coordination' + +function streamInfo(id: string): EmulatorStreamInfo { + return { + deviceUdid: id, + streamUrl: `http://127.0.0.1:3100/${id}/stream.mjpeg`, + wsUrl: `ws://127.0.0.1:3100/${id}/ws` + } +} + +describe('simulator-launch-coordination', () => { + beforeEach(() => { + resetSimulatorLaunchCoordinationForTests() + vi.useFakeTimers() + vi.setSystemTime(1_000) + }) + + afterEach(() => { + resetSimulatorLaunchCoordinationForTests() + vi.useRealTimers() + }) + + it('expires unconsumed sessions at the TTL boundary', () => { + rememberPrelaunchedSimulatorSession('wt-fresh', streamInfo('device-fresh')) + rememberPrelaunchedSimulatorSession('wt-stale', streamInfo('device-stale')) + + vi.advanceTimersByTime(29_999) + + expect(consumePrelaunchedSimulatorSession('wt-fresh')).toMatchObject({ + deviceUdid: 'device-fresh' + }) + + vi.advanceTimersByTime(1) + + expect(consumePrelaunchedSimulatorSession('wt-stale')).toBeNull() + expect(getPrelaunchedSimulatorSessionCountForTests()).toBe(0) + }) + + it('measures handoff age independently of wall-clock corrections', () => { + vi.setSystemTime(100_000) + rememberPrelaunchedSimulatorSession('wt-rollback', streamInfo('device-rollback')) + + vi.setSystemTime(0) + vi.advanceTimersByTime(30_000) + + expect(consumePrelaunchedSimulatorSession('wt-rollback')).toBeNull() + + rememberPrelaunchedSimulatorSession('wt-forward', streamInfo('device-forward')) + vi.setSystemTime(1_000_000) + + expect(consumePrelaunchedSimulatorSession('wt-forward')).toMatchObject({ + deviceUdid: 'device-forward' + }) + }) + + it('caps prelaunched simulator sessions by oldest worktree key', () => { + for (let index = 0; index < 16; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + } + + rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16')) + + expect(consumePrelaunchedSimulatorSession('wt-0')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-1')).toMatchObject({ + deviceUdid: 'device-1' + }) + expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({ + deviceUdid: 'device-16' + }) + }) + + it('treats a refreshed worktree key as the most recently remembered', () => { + for (let index = 0; index < 16; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + } + + rememberPrelaunchedSimulatorSession('wt-0', streamInfo('device-refreshed')) + rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16')) + + expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-0')).toMatchObject({ + deviceUdid: 'device-refreshed' + }) + expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({ + deviceUdid: 'device-16' + }) + }) + + it('stays bounded through prolonged unconsumed-session churn', () => { + for (let index = 0; index < 5_000; index += 1) { + rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`)) + expect(getPrelaunchedSimulatorSessionCountForTests()).toBeLessThanOrEqual(16) + } + + expect(consumePrelaunchedSimulatorSession('wt-4983')).toBeNull() + expect(consumePrelaunchedSimulatorSession('wt-4984')).toMatchObject({ + deviceUdid: 'device-4984' + }) + expect(consumePrelaunchedSimulatorSession('wt-4999')).toMatchObject({ + deviceUdid: 'device-4999' + }) + }) +}) diff --git a/src/renderer/src/lib/simulator-launch-coordination.ts b/src/renderer/src/lib/simulator-launch-coordination.ts index 7f4d05c0830..db363683385 100644 --- a/src/renderer/src/lib/simulator-launch-coordination.ts +++ b/src/renderer/src/lib/simulator-launch-coordination.ts @@ -4,7 +4,31 @@ export const EMULATOR_MANUAL_LAUNCH_STARTED_EVENT = 'orca:emulator-launch-starte export const EMULATOR_MANUAL_LAUNCH_FAILED_EVENT = 'orca:emulator-launch-failed' const manualLaunchesByWorktree = new Set() -const prelaunchedSessionsByWorktree = new Map() +type PrelaunchedSimulatorSession = { + info: EmulatorStreamInfo + rememberedAt: number +} + +// Why: prelaunch info is a short-lived handoff to the pane; if the pane never +// consumes it, stale stream URLs should not stick to removed worktree ids. +const PRELAUNCHED_SIMULATOR_SESSION_TTL_MS = 30_000 +const PRELAUNCHED_SIMULATOR_SESSION_MAX = 16 +const prelaunchedSessionsByWorktree = new Map() + +function prunePrelaunchedSimulatorSessions(now = performance.now()): void { + for (const [worktreeId, entry] of prelaunchedSessionsByWorktree) { + if (now - entry.rememberedAt >= PRELAUNCHED_SIMULATOR_SESSION_TTL_MS) { + prelaunchedSessionsByWorktree.delete(worktreeId) + } + } + while (prelaunchedSessionsByWorktree.size > PRELAUNCHED_SIMULATOR_SESSION_MAX) { + const oldest = prelaunchedSessionsByWorktree.keys().next().value + if (oldest === undefined) { + return + } + prelaunchedSessionsByWorktree.delete(oldest) + } +} export function beginManualSimulatorLaunch(worktreeId: string): void { manualLaunchesByWorktree.add(worktreeId) @@ -25,15 +49,33 @@ export function rememberPrelaunchedSimulatorSession( if (!info?.streamUrl && !info?.wsUrl) { return } - prelaunchedSessionsByWorktree.set(worktreeId, info) + // Why: the TTL measures elapsed handoff age, so wall-clock corrections must + // not resurrect stale stream metadata or discard a fresh handoff. + const rememberedAt = performance.now() + prelaunchedSessionsByWorktree.delete(worktreeId) + prelaunchedSessionsByWorktree.set(worktreeId, { + info, + rememberedAt + }) + prunePrelaunchedSimulatorSessions(rememberedAt) } export function consumePrelaunchedSimulatorSession(worktreeId: string): EmulatorStreamInfo | null { - const info = prelaunchedSessionsByWorktree.get(worktreeId) ?? null + prunePrelaunchedSimulatorSessions() + const info = prelaunchedSessionsByWorktree.get(worktreeId)?.info ?? null prelaunchedSessionsByWorktree.delete(worktreeId) return info } +export function resetSimulatorLaunchCoordinationForTests(): void { + manualLaunchesByWorktree.clear() + prelaunchedSessionsByWorktree.clear() +} + +export function getPrelaunchedSimulatorSessionCountForTests(): number { + return prelaunchedSessionsByWorktree.size +} + export function dispatchManualSimulatorLaunchStarted(worktreeId: string): void { dispatchManualSimulatorLaunchEvent(EMULATOR_MANUAL_LAUNCH_STARTED_EVENT, { worktreeId }) } From f4a9d33ce0d987fc2772b55cdc9005f76a0c1ace Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 05:27:55 -0400 Subject: [PATCH 064/138] fix(files): keep SSH deletes on the remote path (#8218) * fix(files): keep SSH deletes on the remote path (#8135) * fix(files): preserve explorer operation ownership * fix(files): preserve runtime owner caller scope * test(terminal): de-flake stale daemon launch identity ladder assertion Co-authored-by: Orca * fix(files): fail closed on unresolved operation owner in delete flow - Treat non-local operation owners (ssh, runtime, unresolved) as remote for confirm/error copy, then fail closed when the route is null so an unresolved owner never falls through to local filesystem authorization - Fix test setup to preserve existing window properties when stubbing global window, preventing unrelated globals from being clobbered --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../quick-open-file-list.react.test.tsx | 31 ++ .../src/components/quick-open-file-list.ts | 72 ++++- .../components/right-sidebar/FileExplorer.tsx | 2 + .../file-explorer-directory-listing.ts | 60 ++++ .../file-explorer-name-filter-projection.ts | 18 +- .../file-explorer-operation-owner.ts | 146 +++++++++ ...le-explorer-runtime-owner-boundary.test.ts | 3 +- .../right-sidebar/file-explorer-types.ts | 8 + .../right-sidebar/useFileDeletion.ts | 51 ++- .../right-sidebar/useFileExplorerTree.test.ts | 10 +- .../right-sidebar/useFileExplorerTree.ts | 91 +++--- .../terminal-pane/pty-connection.test.ts | 7 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...e-explorer-delete-owner-provenance.test.ts | 242 ++++++++++++++ .../remote-host-file-delete-repro.test.ts | 294 ++++++++++++++++++ 19 files changed, 965 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts create mode 100644 src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts create mode 100644 src/renderer/src/runtime/remote-host-file-delete-repro.test.ts diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index 4a2305e06e3..8dc6c550344 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -191,4 +191,35 @@ describe('useRuntimeFileListForWorktree', () => { expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) }) + + it('does not restart the scan when unrelated ownership metadata changes', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })], + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + + await renderProbe({ enabled: true, onState: () => {}, worktreeId: workspaceKey }) + await waitForListRuntimeFilesCall() + + await act(async () => { + useAppStore.setState({ + repos: [ + { + id: 'unrelated-repo', + path: '/tmp/unrelated', + displayName: 'Unrelated', + badgeColor: '#000', + addedAt: 0 + } + ] + } as Partial) + }) + await flushEffects() + + expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1) + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 13e3738e226..bb539749353 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -1,18 +1,24 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: quick-open file lists are fetched over local or SSH runtime IPC, so loading/error/results track the request lifecycle. */ import { useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { Worktree } from '../../../shared/types' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -import { getConnectionIdFromState } from '@/lib/connection-context' import { createBrowserUuid } from '@/lib/browser-uuid' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { useWorktreesForRepo } from '@/store/selectors' +import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types' +import { + getFileExplorerOperationOwnerFromState, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from '@/components/right-sidebar/file-explorer-operation-owner' export type RuntimeFileListState = { files: string[] loading: boolean loadError: string | null + operationOwner?: FileExplorerOperationOwner } export function cleanRuntimeFileListError(error: unknown): string { @@ -106,6 +112,9 @@ export function useRuntimeFileListForWorktree({ const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) const [loadError, setLoadError] = useState(null) + const [listedOperationOwner, setListedOperationOwner] = useState({ + kind: 'unresolved' + }) const lastRequestKeyRef = useRef('') const target = useMemo( @@ -114,9 +123,28 @@ export function useRuntimeFileListForWorktree({ ) const { excludeRequest } = target - const connectionId = useAppStore( - (state) => getConnectionIdFromState(state, worktreeId) ?? undefined + const operationOwnerState = useAppStore( + useShallow((state) => ({ + settings: state.settings, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + })) ) + const operationOwner = useMemo( + () => getFileExplorerOperationOwnerFromState(operationOwnerState, worktreeId), + [operationOwnerState, worktreeId] + ) + const operationOwnerKey = JSON.stringify(operationOwner) + const operationOwnerRef = useRef(operationOwner) + operationOwnerRef.current = operationOwner + const operationRoute = getFileExplorerOperationRoute(operationOwner) + const operationRouteAvailable = operationRoute !== null + const connectionId = operationRoute?.connectionId + const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null const activeTargetStatus = useAppStore((state) => connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined ) @@ -126,19 +154,21 @@ export function useRuntimeFileListForWorktree({ activeTargetStatus === 'reconnecting' const requestKey = useMemo( () => - `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`, - [activeTargetStatus, connectionId, excludeRequest.key, worktreePath] + `${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`, + [activeTargetStatus, excludeRequest.key, operationOwnerKey, worktreePath] ) useEffect(() => { if (!enabled) { setLoading(false) + setListedOperationOwner({ kind: 'unresolved' }) return } - if (!target.canList || !worktreeId || !worktreePath) { + if (!target.canList || !worktreeId || !worktreePath || !operationRouteAvailable) { setFiles([]) - setLoadError(null) + setListedOperationOwner({ kind: 'unresolved' }) + setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage()) setLoading(false) return } @@ -154,10 +184,9 @@ export function useRuntimeFileListForWorktree({ const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined const requestToken = createBrowserUuid() + const requestOperationOwner = operationOwnerRef.current const requestContext = { - // Why: Quick Open lists files for the selected workspace. It must - // follow that workspace's owner host, not the globally focused host. - settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), + settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, worktreeId, worktreePath, connectionId @@ -171,6 +200,7 @@ export function useRuntimeFileListForWorktree({ .then((result) => { if (!cancelled) { setFiles(result) + setListedOperationOwner(requestOperationOwner) } }) .catch((error) => { @@ -193,7 +223,23 @@ export function useRuntimeFileListForWorktree({ // 30s timeout ("Could not load files for this workspace"). cancelRuntimeFileList(requestContext, requestToken) } - }, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath]) + }, [ + enabled, + excludeRequest, + connectionId, + operationOwnerKey, + operationRouteAvailable, + requestKey, + runtimeEnvironmentId, + target.canList, + worktreeId, + worktreePath + ]) - return { files, loading: loading || connectionPending, loadError } + return { + files, + loading: loading || connectionPending, + loadError, + operationOwner: listedOperationOwner + } } diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index fb91c21963b..9591084e5b9 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -163,6 +163,7 @@ function FileExplorerFiles(): React.JSX.Element { hasNameFilter ? { query: nameFilterQuery, + operationOwner: nameFilterFiles.operationOwner, relativePaths: nameFilterQueryTooLarge ? [] : nameFilterFiles.loading && nameFilterFiles.files.length === 0 @@ -174,6 +175,7 @@ function FileExplorerFiles(): React.JSX.Element { hasNameFilter, nameFilterFiles.files, nameFilterFiles.loading, + nameFilterFiles.operationOwner, nameFilterQuery, nameFilterQueryTooLarge ] diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts new file mode 100644 index 00000000000..13742226c9a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -0,0 +1,60 @@ +import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { DirEntry } from '../../../../shared/types' +import { readRuntimeDirectory } from '@/runtime/runtime-file-client' +import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' +import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' +import { + getFileExplorerOperationOwner, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from './file-explorer-operation-owner' + +export type FileExplorerDirectoryListing = { + entries: DirEntry[] + operationOwner: FileExplorerOperationOwner +} + +export function fileExplorerEntriesToTreeNodes( + entries: DirEntry[], + dirPath: string, + depth: number, + worktreePath: string | null, + operationOwner: FileExplorerOperationOwner +): TreeNode[] { + return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { + const path = joinPath(dirPath, entry.name) + return { + name: entry.name, + path, + relativePath: worktreePath + ? normalizeRelativePath(path.slice(worktreePath.length + 1)) + : entry.name, + isDirectory: entry.isDirectory, + isSymlink: entry.isSymlink, + depth: depth + 1, + operationOwner + } + }) +} + +export async function readFileExplorerDirectory( + activeWorktreeId: string | null | undefined, + worktreePath: string | null, + dirPath: string +): Promise { + const operationOwner = getFileExplorerOperationOwner(activeWorktreeId) + const route = getFileExplorerOperationRoute(operationOwner) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } + const entries = await readRuntimeDirectory( + { + settings: route.settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: route.connectionId + }, + dirPath + ) + return { entries, operationOwner } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index 551b76bf681..ba057328924 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -1,6 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import type { TreeNode } from './file-explorer-types' +import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { createFileExplorerRowProjectionFromParts, type FileExplorerRowProjection @@ -12,6 +12,7 @@ import { isPathIgnored } from './status-display' export type FileExplorerNameFilterProjectionSource = { query: string relativePaths: readonly string[] | null + operationOwner?: FileExplorerOperationOwner } export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024 @@ -133,14 +134,16 @@ function createSyntheticNode( relativePath: string, name: string, depth: number, - isDirectory: boolean + isDirectory: boolean, + operationOwner: FileExplorerOperationOwner | undefined ): TreeNode { return { name, path: joinPath(worktreePath, relativePath), relativePath, isDirectory, - depth + depth, + operationOwner } } @@ -197,7 +200,14 @@ export function createNameFilteredFileExplorerProjection({ let entry = currentChildren.get(name) if (!entry) { entry = { - node: createSyntheticNode(worktreePath, currentRelativePath, name, index, isDirectory), + node: createSyntheticNode( + worktreePath, + currentRelativePath, + name, + index, + isDirectory, + nameFilter.operationOwner + ), children: new Map() } currentChildren.set(name, entry) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts new file mode 100644 index 00000000000..451d0fd807d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -0,0 +1,146 @@ +import { getConnectionIdFromState } from '@/lib/connection-context' +import { useAppStore } from '@/store' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { translate } from '@/i18n/i18n' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getSettingsForWorktreeRuntimeOwner +} from '@/lib/worktree-runtime-owner' +import type { FileExplorerOperationOwner } from './file-explorer-types' + +export type FileExplorerOperationRoute = { + settings: { activeRuntimeEnvironmentId: string | null } + connectionId?: string +} + +type FileExplorerOwnerState = Pick< + AppState, + | 'settings' + | 'repos' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' +> + +export function getFileExplorerOperationOwnerFromState( + state: FileExplorerOwnerState, + worktreeId: string | null | undefined +): FileExplorerOperationOwner { + const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null + if (worktreeId && parsedWorkspace?.type !== 'folder') { + const exactHostIds = getExactWorktreeHostIds(state, worktreeId) + if (exactHostIds.size > 1) { + return { kind: 'unresolved' } + } + const exactHostId = exactHostIds.values().next().value + if (exactHostId) { + return operationOwnerFromHostId(exactHostId) + } + + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repoHostIds = new Set( + state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId) + ) + if (repoHostIds.size > 1) { + return { kind: 'unresolved' } + } + } + + const connectionId = getConnectionIdFromState(state, worktreeId ?? null) + const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + // Why: global runtime focus is not ownership evidence while SSH/local + // metadata is unresolved; destructive actions must wait for explicit provenance. + if (connectionId === undefined && explicitRuntimeEnvironmentId === null) { + return { kind: 'unresolved' } + } + const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId) + // Why: inferred SSH ownership outranks global runtime focus, but an explicit + // workspace runtime still owns its files. + const runtimeEnvironmentId = + connectionId && explicitRuntimeEnvironmentId === null + ? null + : settings.activeRuntimeEnvironmentId?.trim() + if (runtimeEnvironmentId) { + return { kind: 'runtime', environmentId: runtimeEnvironmentId } + } + if (connectionId === undefined) { + return { kind: 'unresolved' } + } + return connectionId ? { kind: 'ssh', connectionId } : { kind: 'local' } +} + +export function getFileExplorerOperationOwner( + worktreeId: string | null | undefined +): FileExplorerOperationOwner { + return getFileExplorerOperationOwnerFromState(useAppStore.getState(), worktreeId) +} + +export function getFileExplorerOperationRoute( + owner: FileExplorerOperationOwner +): FileExplorerOperationRoute | null { + switch (owner.kind) { + case 'local': + return { settings: { activeRuntimeEnvironmentId: null } } + case 'ssh': + return { + settings: { activeRuntimeEnvironmentId: null }, + connectionId: owner.connectionId + } + case 'runtime': + return { settings: { activeRuntimeEnvironmentId: owner.environmentId } } + case 'unresolved': + return null + } +} + +export function getFileExplorerOwnerUnresolvedMessage(): string { + return translate( + 'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved', + "Couldn't determine which host owns this workspace. Check the connection and try again." + ) +} + +function getExactWorktreeHostIds( + state: Pick, + worktreeId: string +): Set { + const hostIds = new Set() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + return hostIds +} + +function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner { + const parsed = parseExecutionHostId(hostId) + switch (parsed?.kind) { + case 'local': + return { kind: 'local' } + case 'ssh': + return { kind: 'ssh', connectionId: parsed.targetId } + case 'runtime': + return { kind: 'runtime', environmentId: parsed.environmentId } + case undefined: + return { kind: 'unresolved' } + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts index 19c8b2a9bee..b4366a4d3c0 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -15,6 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { 'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts', 'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts', 'src/renderer/src/components/right-sidebar/useFileDuplicate.ts', + 'src/renderer/src/components/right-sidebar/useFileDeletion.ts', 'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts', 'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts', 'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts', @@ -23,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => { const text = source(path) expect(text).toMatch( - /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/ + /getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/ ) expect(text).not.toContain('settings: useAppStore.getState().settings') expect(text).not.toContain('const settings = useAppStore.getState().settings') diff --git a/src/renderer/src/components/right-sidebar/file-explorer-types.ts b/src/renderer/src/components/right-sidebar/file-explorer-types.ts index 8027f25cefe..d8a773d3f6c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-types.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-types.ts @@ -1,3 +1,9 @@ +export type FileExplorerOperationOwner = + | { kind: 'local' } + | { kind: 'ssh'; connectionId: string } + | { kind: 'runtime'; environmentId: string } + | { kind: 'unresolved' } + export type TreeNode = { name: string path: string @@ -5,6 +11,8 @@ export type TreeNode = { isDirectory: boolean isSymlink?: boolean depth: number + /** Host snapshot that produced this node. Destructive actions fail closed without it. */ + operationOwner?: FileExplorerOperationOwner } export type DirCache = { diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 26319001620..4d868b7bf1b 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -3,11 +3,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { dirname } from '@/lib/path' -import { getConnectionId } from '@/lib/connection-context' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { findWorktreeById } from '@/store/slices/worktree-helpers' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { TreeNode } from './file-explorer-types' +import { getFileExplorerOperationRoute } from './file-explorer-operation-owner' import { requestEditorFileSave, requestEditorSaveQuiesce @@ -15,7 +14,6 @@ import { import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { deleteRuntimePath, - isRemoteRuntimeFileOperation, readRuntimeFileContent, writeRuntimeFile } from '@/runtime/runtime-file-client' @@ -50,6 +48,10 @@ export function useFileDeletion({ }: UseFileDeletionParams): UseFileDeletionResult { const confirm = useConfirmationDialog() const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete') + const unresolvedDeleteOwnerError = translate( + 'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f', + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) // Why: track in-flight deletes per-path so repeated Del presses on the same // node don't issue duplicate IPC calls; the map is a ref to avoid re-renders. const inFlightRef = useRef>(new Set()) @@ -61,21 +63,28 @@ export function useFileDeletion({ } inFlightRef.current.add(node.path) - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const state = useAppStore.getState() - const worktree = activeWorktreeId - ? findWorktreeById(state.worktreesByRepo, activeWorktreeId) - : null - const fileContext = { - settings: state.settings, - worktreeId: activeWorktreeId, - worktreePath: worktree?.path ?? null, - connectionId - } - const isRemote = - connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path) + const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const } + const operationRoute = getFileExplorerOperationRoute(operationOwner) + // Why: treat every non-local owner (ssh, runtime, unresolved) as remote + // for confirm/error copy, then fail closed below when the route is null + // so an unresolved owner never reaches local filesystem authorization. + const isRemote = operationOwner.kind !== 'local' try { + if (!operationRoute) { + throw new Error(unresolvedDeleteOwnerError) + } + // Why: cached nodes can outlive host hydration changes; preserve the + // listing-time owner so deletion cannot jump to a same-path file elsewhere. + const state = useAppStore.getState() + const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null + const connectionId = operationRoute.connectionId + const fileContext = { + settings: operationRoute.settings, + worktreeId: activeWorktreeId, + worktreePath: worktree?.path ?? null, + connectionId + } // Why: remote deletes bypass OS Trash, and undo cannot recover // directories or unreadable files. if (isRemote) { @@ -206,7 +215,15 @@ export function useFileDeletion({ inFlightRef.current.delete(node.path) } }, - [activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir] + [ + activeWorktreeId, + closeFile, + confirm, + isWindows, + openFiles, + refreshDir, + unresolvedDeleteOwnerError + ] ) const requestDelete = useCallback( diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts index 359a353b6d7..b98e61c825e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts @@ -33,7 +33,7 @@ describe('refreshFileExplorerExpandedDirs', () => { '/repo/src': [entry('index.ts')], '/repo/docs': [entry('guide.md')] } - return entriesByPath[dirPath] ?? [] + return { entries: entriesByPath[dirPath] ?? [], operationOwner: { kind: 'local' as const } } }) const refreshed = await refreshFileExplorerExpandedDirs({ @@ -115,9 +115,9 @@ describe('refreshFileExplorerExpandedDirs', () => { ...prev, '/repo/src': newerSrcCache })) - return [entry('stale.ts')] + return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } } } - return [entry('guide.md')] + return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } } }) const refreshed = await refreshFileExplorerExpandedDirs({ @@ -158,10 +158,10 @@ describe('refreshFileExplorerExpandedDirs', () => { }) const readDirectory = vi.fn(async (dirPath: string) => { if (dirPath === '/repo/src') { - return [entry('stale.ts')] + return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } } } await docsGate - return [entry('guide.md')] + return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } } }) const refreshPromise = refreshFileExplorerExpandedDirs({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index dbbd73a1901..b4dc1167323 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,17 +1,22 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' -import { joinPath, normalizeRelativePath } from '@/lib/path' -import { getConnectionId } from '@/lib/connection-context' -import type { DirEntry } from '../../../../shared/types' -import type { DirCache, TreeNode } from './file-explorer-types' +import type { DirCache } from './file-explorer-types' import { splitPathSegments } from './path-tree' -import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' -import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' +import { statRuntimePath } from '@/runtime/runtime-file-client' import { createFileExplorerDirLoadTracker, type FileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { + getFileExplorerOperationOwner, + getFileExplorerOwnerUnresolvedMessage, + getFileExplorerOperationRoute +} from './file-explorer-operation-owner' +import { + fileExplorerEntriesToTreeNodes, + readFileExplorerDirectory, + type FileExplorerDirectoryListing +} from './file-explorer-directory-listing' type UseFileExplorerTreeResult = { dirCache: Record @@ -40,45 +45,7 @@ type RefreshFileExplorerExpandedDirsParams = { worktreePath: string dirLoadTracker: FileExplorerDirLoadTracker setDirCache: Dispatch>> - readDirectory: (dirPath: string) => Promise -} - -function entriesToTreeNodes( - entries: DirEntry[], - dirPath: string, - depth: number, - worktreePath: string | null -): TreeNode[] { - return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { - const path = joinPath(dirPath, entry.name) - return { - name: entry.name, - path, - relativePath: worktreePath - ? normalizeRelativePath(path.slice(worktreePath.length + 1)) - : entry.name, - isDirectory: entry.isDirectory, - isSymlink: entry.isSymlink, - depth: depth + 1 - } - }) -} - -async function readWorktreeDirectory( - activeWorktreeId: string | null | undefined, - worktreePath: string | null, - dirPath: string -): Promise { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - return readRuntimeDirectory( - { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - dirPath - ) + readDirectory: (dirPath: string) => Promise } export async function refreshFileExplorerExpandedDirs({ @@ -115,7 +82,7 @@ export async function refreshFileExplorerExpandedDirs({ uniqueDirs.map(async ({ dirPath, depth }) => { const loadToken = loadTokens.get(dirPath)! try { - const entries = await readDirectory(dirPath) + const listing = await readDirectory(dirPath) if (!dirLoadTracker.isCurrent(loadToken)) { return { current: false as const } } @@ -123,7 +90,13 @@ export async function refreshFileExplorerExpandedDirs({ current: true as const, dirPath, cache: { - children: entriesToTreeNodes(entries, dirPath, depth, worktreePath), + children: fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ), loading: false } } @@ -197,14 +170,20 @@ export function useFileExplorerTree( } })) try { - const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) + const listing = await readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) if (!dirLoadTrackerRef.current.isCurrent(loadToken)) { return false } if (depth === -1) { setRootError(null) } - const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath) + const children = fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ) setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } })) return true } catch (error) { @@ -247,13 +226,17 @@ export function useFileExplorerTree( const statPath = useCallback( async (path: string) => { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + const operationOwner = getFileExplorerOperationOwner(activeWorktreeId) + const route = getFileExplorerOperationRoute(operationOwner) + if (!route) { + throw new Error(getFileExplorerOwnerUnresolvedMessage()) + } return statRuntimePath( { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + settings: route.settings, worktreeId: activeWorktreeId, worktreePath, - connectionId + connectionId: route.connectionId }, path ) @@ -286,7 +269,7 @@ export function useFileExplorerTree( worktreePath, dirLoadTracker: dirLoadTrackerRef.current, setDirCache, - readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) + readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) }) }, [activeWorktreeId, expanded, loadDir, worktreePath]) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 081b22be937..f04eb1757f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17714,7 +17714,12 @@ describe('connectPanePty', () => { ) await flushAsyncTicks() - expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3) + // The bounded ladder runs at least its initial read plus two retries; an + // incidental droid reconfirm can add one more, so assert the floor, not an + // exact count (matches the warm-reattach-to-shell sibling test above). + expect( + vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length + ).toBeGreaterThanOrEqual(3) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: null, shellForeground: true diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b920423d8a4..d9e2d1e3bb8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "Couldn't determine which host owns this workspace. Check the connection and try again." + }, "useFileDeletion": { "72691dfebc": "Failed to {{value0}} '{{value1}}'.", "96affe1302": "'{{value0}}' moved to {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructive", "92276aceb7": "Delete", "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", + "8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again." }, "useFileExplorerHandlers": { "32cd9fd991": "Cannot open symlink target" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 64015250692..e30c7b66f9f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "No se pudo determinar qué host posee este espacio de trabajo. Comprueba la conexión e inténtalo de nuevo." + }, "useFileDeletion": { "72691dfebc": "No se pudo {{value0}} '{{value1}}'.", "96affe1302": "'{{value0}}' se movió a {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructivo", "92276aceb7": "Eliminar", "7fb9435c86": "Esto elimina permanentemente el directorio y su contenido en el host remoto. Esto no se puede deshacer.", - "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer." + "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.", + "8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo." }, "useFileExplorerHandlers": { "32cd9fd991": "No se puede abrir el destino del enlace simbólico" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b7b02bf19eb..1651fbb09d2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "このワークスペースを所有しているホストを特定できませんでした。接続を確認して、もう一度お試しください。" + }, "useFileDeletion": { "72691dfebc": "{{value0}} '{{value1}}' に失敗しました。", "96affe1302": "「{{value0}}」は {{value1}} に移動されました", @@ -9857,7 +9860,8 @@ "a76c74f105": "破壊的な", "92276aceb7": "削除", "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", + "8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。" }, "useFileExplorerHandlers": { "32cd9fd991": "シンボリックリンクターゲットを開けません" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5d24f4f3ca9..f5af8bbdb59 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "이 워크스페이스를 소유한 호스트를 확인할 수 없습니다. 연결을 확인한 후 다시 시도하세요." + }, "useFileDeletion": { "72691dfebc": "{{value0}} '{{value1}}'에 실패했습니다.", "96affe1302": "'{{value0}}'이(가) {{value1}}(으)로 이동되었습니다.", @@ -9857,7 +9860,8 @@ "a76c74f105": "파괴적", "92276aceb7": "삭제", "7fb9435c86": "원격 호스트의 디렉터리와 그 안의 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", - "23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다." + "23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요." }, "useFileExplorerHandlers": { "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8ef27b22171..c4eb2fdbe8f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9849,6 +9849,9 @@ } } }, + "fileExplorerOperationOwner": { + "unresolved": "无法确定哪个主机拥有此工作区。请检查连接后重试。" + }, "useFileDeletion": { "72691dfebc": "无法 {{value0}}“{{value1}}”。", "96affe1302": "“{{value0}}”已移至 {{value1}}", @@ -9857,7 +9860,8 @@ "a76c74f105": "destructive", "92276aceb7": "删除", "7fb9435c86": "这会永久删除远程主机上的目录及其内容。此操作无法撤销。", - "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。" + "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。", + "8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。" }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" diff --git a/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts b/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts new file mode 100644 index 00000000000..75e2bfa6bd5 --- /dev/null +++ b/src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../shared/types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useAppStore } from '@/store' +import { useFileDeletion } from '@/components/right-sidebar/useFileDeletion' +import { getFileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-operation-owner' +import type { + FileExplorerOperationOwner, + TreeNode +} from '@/components/right-sidebar/file-explorer-types' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' + +const { confirm, toastError } = vi.hoisted(() => ({ + confirm: vi.fn(), + toastError: vi.fn() +})) +const fsReadFile = vi.fn() +const fsDeletePath = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +vi.mock('@/components/confirmation-dialog', () => ({ useConfirmationDialog: () => confirm })) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Delete' })) +vi.mock('@/components/editor/editor-autosave', () => ({ + requestEditorFileSave: vi.fn().mockResolvedValue(undefined), + requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@/components/right-sidebar/fileExplorerUndoRedo', () => ({ + commitFileExplorerOp: vi.fn() +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +const initialState = useAppStore.getInitialState() +const SSH_ID = 'ssh-target-1' +const FOLDER_ID = 'folder-workspace-1' +const LOCAL_REPO_ID = 'repo-shared' +const LOCAL_WORKTREE_ID = `${LOCAL_REPO_ID}::/tmp/project` +const localNode: TreeNode = { + name: 'index.ts', + path: '/tmp/project/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'local' } +} + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { displayName: overrides.id, badgeColor: '#000', addedAt: 0, ...overrides } +} + +function makeWorktree(hostId: Worktree['hostId']): Worktree { + return { + id: LOCAL_WORKTREE_ID, + repoId: LOCAL_REPO_ID, + path: '/tmp/project', + hostId + } as Worktree +} + +function folderWorkspace(): FolderWorkspace { + return { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'Remote workspace', + folderPath: '/home/user/project', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Remote workspace', + parentPath: '/home/user/project', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function renderDelete(activeWorktreeId: string) { + return renderHook(() => + useFileDeletion({ + activeWorktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) +} + +async function requestDelete( + result: ReturnType['result'], + node: TreeNode, + operationOwner: FileExplorerOperationOwner +): Promise { + await act(async () => { + result.current.requestDelete({ ...node, operationOwner }) + }) +} + +beforeEach(() => { + confirm.mockReset().mockResolvedValue(true) + toastError.mockReset() + fsReadFile.mockReset().mockResolvedValue({ content: 'content', isBinary: false }) + fsDeletePath.mockReset().mockResolvedValue(undefined) + runtimeEnvironmentCall + .mockReset() + .mockImplementation((args: { selector?: string; method: string }) => { + const runtimeId = args.selector ?? 'env-1' + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, runtimeId) ?? { + id: 'rpc-1', + ok: true, + result: null, + _meta: { runtimeId } + } + ) + }) + vi.stubGlobal('window', { + api: { + fs: { readFile: fsReadFile, deletePath: fsDeletePath }, + runtime: { call: vi.fn() }, + runtimeEnvironments: { call: runtimeEnvironmentCall, subscribe: vi.fn() } + } + }) +}) + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() +}) + +describe('file explorer deletion owner provenance', () => { + it('keeps a cached runtime node on its listing host after SSH hydration', async () => { + const workspaceId = folderWorkspaceKey(FOLDER_ID) + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + repos: [], + worktreesByRepo: {} + }) + const listingOwner = getFileExplorerOperationOwner(workspaceId) + expect(listingOwner).toEqual({ kind: 'runtime', environmentId: 'focused-env' }) + useAppStore.setState({ + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/home/user/project', + connectionId: SSH_ID, + projectGroupId: 'group-1' + }) + ] + }) + expect(getFileExplorerOperationOwner(workspaceId)).toEqual({ + kind: 'ssh', + connectionId: SSH_ID + }) + + const { result } = renderDelete(workspaceId) + await requestDelete( + result, + { ...localNode, path: '/home/user/project/src/index.ts' }, + listingOwner + ) + + await vi.waitFor(() => + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'focused-env', method: 'files.delete' }) + ) + ) + expect(fsDeletePath).not.toHaveBeenCalled() + }) + + it('fails closed when an exact worktree ID belongs to multiple hosts', async () => { + useAppStore.setState({ + repos: duplicateHostRepos(), + worktreesByRepo: { + [LOCAL_REPO_ID]: [makeWorktree('local'), makeWorktree(`ssh:${SSH_ID}`)] + } + }) + const owner = getFileExplorerOperationOwner(LOCAL_WORKTREE_ID) + expect(owner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(LOCAL_WORKTREE_ID) + await requestDelete(result, localNode, owner) + + await vi.waitFor(() => expect(toastError).toHaveBeenCalled()) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('keeps an explicit local worktree local when duplicate repo IDs include SSH', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + repos: duplicateHostRepos(), + worktreesByRepo: { [LOCAL_REPO_ID]: [makeWorktree('local')] } + }) + const owner = getFileExplorerOperationOwner(LOCAL_WORKTREE_ID) + expect(owner).toEqual({ kind: 'local' }) + + const { result } = renderDelete(LOCAL_WORKTREE_ID) + await requestDelete(result, localNode, owner) + + await vi.waitFor(() => + expect(fsDeletePath).toHaveBeenCalledWith({ + targetPath: localNode.path, + connectionId: undefined, + recursive: false + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) + +function duplicateHostRepos(): Repo[] { + return [ + makeRepo({ id: LOCAL_REPO_ID, path: '/tmp/project', executionHostId: 'local' }), + makeRepo({ id: LOCAL_REPO_ID, path: '/home/user/project', connectionId: SSH_ID }) + ] +} diff --git a/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts b/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts new file mode 100644 index 00000000000..b29856b4d87 --- /dev/null +++ b/src/renderer/src/runtime/remote-host-file-delete-repro.test.ts @@ -0,0 +1,294 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' +import { useAppStore } from '@/store' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useFileDeletion } from '@/components/right-sidebar/useFileDeletion' +import { getFileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-operation-owner' +import type { TreeNode } from '@/components/right-sidebar/file-explorer-types' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' + +const { confirm, toastError } = vi.hoisted(() => ({ + confirm: vi.fn(), + toastError: vi.fn() +})) +const fsReadFile = vi.fn() +const fsDeletePath = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +vi.mock('@/components/confirmation-dialog', () => ({ + useConfirmationDialog: () => confirm +})) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Delete' })) +vi.mock('@/components/editor/editor-autosave', () => ({ + requestEditorFileSave: vi.fn().mockResolvedValue(undefined), + requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@/components/right-sidebar/fileExplorerUndoRedo', () => ({ + commitFileExplorerOp: vi.fn() +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +const initialState = useAppStore.getInitialState() +const SSH_CONNECTION_ID = 'ssh-target-1' +const REMOTE_PATH = '/home/user/project/src/index.ts' +const REMOTE_REPO_ID = 'repo-ssh' +const REMOTE_WORKTREE_ID = `${REMOTE_REPO_ID}::/home/user/project` +const FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const LOCAL_PATH = '/tmp/project/src/index.ts' + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: FOLDER_WORKSPACE_ID, + projectGroupId: 'group-1', + name: 'Remote workspace', + folderPath: '/home/user/project', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeProjectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'Remote workspace', + parentPath: '/home/user/project', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { + displayName: overrides.id, + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +const remoteFile: TreeNode = { + name: 'index.ts', + path: REMOTE_PATH, + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'ssh', connectionId: SSH_CONNECTION_ID } +} + +const localFile: TreeNode = { + name: 'index.ts', + path: LOCAL_PATH, + relativePath: 'src/index.ts', + isDirectory: false, + depth: 0, + operationOwner: { kind: 'local' } +} + +function renderDelete(activeWorktreeId: string) { + return renderHook(() => + useFileDeletion({ + activeWorktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) +} + +beforeEach(() => { + confirm.mockReset().mockResolvedValue(true) + fsReadFile.mockReset().mockResolvedValue({ content: 'remote', isBinary: false }) + fsDeletePath.mockReset().mockResolvedValue(undefined) + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentCall.mockImplementation((args: { method: string; selector?: string }) => { + const runtimeId = args.selector ?? 'env-1' + return ( + createCompatibleRuntimeStatusResponseIfNeeded(args, runtimeId) ?? { + id: 'rpc-1', + ok: true, + result: null, + _meta: { runtimeId } + } + ) + }) + toastError.mockReset() + vi.stubGlobal('window', { + ...window, + api: { + fs: { readFile: fsReadFile, deletePath: fsDeletePath }, + runtime: { call: vi.fn() }, + runtimeEnvironments: { call: runtimeEnvironmentCall, subscribe: vi.fn() } + } + }) +}) + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() +}) + +describe('issue #8135: deleting a remote SSH folder file', () => { + it('keeps an inferred SSH folder workspace off the focused runtime', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [ + makeRepo({ + id: REMOTE_REPO_ID, + path: '/home/user/project', + connectionId: SSH_CONNECTION_ID, + projectGroupId: 'group-1' + }) + ], + worktreesByRepo: {} + }) + + expect(getFileExplorerOperationOwner(folderWorkspaceKey(FOLDER_WORKSPACE_ID))).toEqual({ + kind: 'ssh', + connectionId: SSH_CONNECTION_ID + }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete(remoteFile) + }) + + await vi.waitFor(() => { + expect(fsDeletePath).toHaveBeenCalledWith({ + targetPath: REMOTE_PATH, + connectionId: SSH_CONNECTION_ID, + recursive: false + }) + }) + expect(toastError).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledTimes(1) + }) + + it('fails closed for an ambiguous folder even when a runtime is focused', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace()], + projectGroups: [makeProjectGroup()], + repos: [ + makeRepo({ + id: 'repo-local', + path: '/home/user/project/local', + projectGroupId: 'group-1' + }), + makeRepo({ + id: 'repo-ssh', + path: '/home/user/project', + connectionId: SSH_CONNECTION_ID, + projectGroupId: 'group-1' + }) + ], + worktreesByRepo: {} + }) + const operationOwner = getFileExplorerOperationOwner(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + expect(operationOwner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete({ ...remoteFile, operationOwner }) + }) + + await vi.waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) + }) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(confirm).not.toHaveBeenCalled() + }) + + it('stops while the SSH owner is unresolved instead of deleting locally', async () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: {} + }) + const operationOwner = getFileExplorerOperationOwner(REMOTE_WORKTREE_ID) + expect(operationOwner).toEqual({ kind: 'unresolved' }) + + const { result } = renderDelete(REMOTE_WORKTREE_ID) + + await act(async () => { + result.current.requestDelete({ ...remoteFile, operationOwner }) + }) + + await vi.waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + "Couldn't determine which host owns this file. Check the workspace connection and try again." + ) + }) + expect(fsReadFile).not.toHaveBeenCalled() + expect(fsDeletePath).not.toHaveBeenCalled() + }) + + it('routes a runtime-owned folder workspace with its synthetic worktree path', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as never, + folderWorkspaces: [makeFolderWorkspace({ folderPath: '/tmp/project' })], + projectGroups: [ + makeProjectGroup({ + parentPath: '/tmp/project', + executionHostId: 'runtime:env-1' + }) + ], + repos: [], + worktreesByRepo: {} + }) + + const { result } = renderDelete(folderWorkspaceKey(FOLDER_WORKSPACE_ID)) + + await act(async () => { + result.current.requestDelete({ + ...localFile, + operationOwner: { kind: 'runtime', environmentId: 'env-1' } + }) + }) + + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.delete', + params: { + worktree: `id:${folderWorkspaceKey(FOLDER_WORKSPACE_ID)}`, + relativePath: 'src/index.ts', + recursive: false + }, + timeoutMs: 15_000 + }) + }) + expect(fsDeletePath).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledTimes(1) + }) +}) From dcfa91919fa6b758e02d434a9f30b414ec233252 Mon Sep 17 00:00:00 2001 From: Brandon Bennett <107384180+branben@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:35:01 +0000 Subject: [PATCH 065/138] fix(automations): deliver Hermes prompts via native query (#7862) * fix(automations): deliver prompt to Hermes TUI via process-ready signal Hermes's prompt_toolkit TUI never emits the DECSET 2004 bracketed-paste handshake that the default draft-paste readiness waiter gates on, so the automation prompt was silently dropped and the agent sat idle (terminal opens, nothing entered). Add a 'process-ready' DraftPasteReadySignal that arms the quiet-window on first PTY output and pastes once the TUI settles, and assign it to the hermes agent config. Adds a Hermes unit test covering the no-handshake path. Design for the follow-up PR/issue review loop lives in docs/design/pr-issue-review-loop.md (not committed; gitignored). * fix(automations): skip broken process-name fallback for process-ready CodeRabbit: the process-ready fallback consulted waitForAgentReady, which compares the foreground process basename via isExpectedAgentProcess. Wrapped interpreter launches surface as 'python3 .../hermes', so the check can never confirm readiness and only drops the paste. process-ready readiness is the PTY-quiet window (handled by waitForAgentDraftInputReady); the process-name fallback is dead for this signal. Skip it and return false on timeout instead. * test(automations): assert single paste for Hermes quiet-window path Guard against a duplicate paste in the no-handshake Hermes path by asserting sendRuntimePtyInputVerified is called exactly once. Addresses the CodeRabbit nitpick on PR #7862. * fix(automations): skip broken process-name fallback on pty-bound paste path pasteDraftToAgentPtyWhenReady (quick-create/work-item route) still ran the python3-vs-hermes process-name fallback that can never match for process-ready agents, burning ~1s and dropping the paste. Mirror the process-ready guard from pasteDraftWhenAgentReady and surface onTimeout. Adds regression tests for both the quiet-window happy path and the fallback-skip on the pty-bound path. Co-authored-by: Orca * fix(automations): defer submit Enter until the Hermes TUI is interactive Live-testing against Hermes v0.18.2 showed the fixed 50ms post-paste Enter is swallowed: the node ui-tui takes 15s+ to boot, the paste fires ~1.5s in (process-ready quiet window), and the cooked-mode line discipline turns the early \r into \n, which the editor treats as newline-insert. The prompt parked in the input box and the automation never executed. For process-ready agents, defer the Enter until the TUI signals interactivity (DECSET 2004 enable) or echoes the pasted content marker-free (legacy prompt_toolkit), with a 120s best-effort cap. PTY input is FIFO, so the deferred Enter always lands after the buffered paste text. The paste echo of the content itself (raw or caret-notation markers adjacent) is rejected so a cooked-mode echo can't release the Enter early. Verified end-to-end in the dev app: automation prompt pasted, deferred Enter released on tui-ready, Hermes submitted and ran the turn. Co-authored-by: Orca * fix(automations): use Hermes native startup query Co-authored-by: Orca * fix(automations): preserve quoted Hermes queries on Windows * fix(agents): preserve invalid argument error message Co-authored-by: Orca --------- Co-authored-by: Brandon Bennett Co-authored-by: Jinwoo-H Co-authored-by: Orca Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/daemon/pty-subprocess.test.ts | 8 +- src/main/daemon/pty-subprocess.ts | 6 + src/main/providers/local-pty-provider.test.ts | 11 +- src/main/providers/local-pty-provider.ts | 6 + .../buildSourceControlAgentDeliveryPlan.ts | 1 + .../folder-workspace-composer-submit.test.ts | 25 ++ .../folder-workspace-composer-submit.ts | 16 +- src/renderer/src/hooks/useComposerState.ts | 13 + .../lib/agent-background-session-cleanup.ts | 9 + .../github-work-item-background-request.ts | 9 + .../launch-agent-background-session.test.ts | 62 ++++ .../lib/launch-agent-background-session.ts | 29 +- .../source-control-agent-action-plan.test.ts | 26 ++ .../lib/source-control-agent-action-plan.ts | 14 +- src/shared/agent-draft-platform-limit.ts | 18 ++ src/shared/hermes-startup-query.ts | 203 ++++++++++++ .../hermes-startup-query.windows.test.ts | 52 +++ src/shared/sleeping-agent-launch-config.ts | 15 + src/shared/tui-agent-config.ts | 5 +- src/shared/tui-agent-startup-shell.ts | 67 +++- src/shared/tui-agent-startup.test.ts | 303 +++++++++++++++++- src/shared/tui-agent-startup.ts | 68 ++-- 22 files changed, 908 insertions(+), 58 deletions(-) create mode 100644 src/renderer/src/lib/agent-background-session-cleanup.ts create mode 100644 src/shared/agent-draft-platform-limit.ts create mode 100644 src/shared/hermes-startup-query.ts create mode 100644 src/shared/hermes-startup-query.windows.test.ts create mode 100644 src/shared/sleeping-agent-launch-config.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 908ef9a68b2..ca21dd5e58b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2291,6 +2291,7 @@ describe('createPtySubprocess', () => { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', env: { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_HERMES_STARTUP_QUERY: 'line one\nline two', WSLENV: 'FOO/u' } }) @@ -2317,7 +2318,12 @@ describe('createPtySubprocess', () => { // Why: the daemon inherits optional agent-hook env in development. This // test owns only the terminal handle and Powerlevel10k WSLENV contract. expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( - expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV]) + expect.arrayContaining([ + 'FOO/u', + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 5d36ebe9d69..6a285301406 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -56,6 +56,7 @@ import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -717,6 +718,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 57748417cac..66f68d6e868 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -675,7 +675,8 @@ describe('LocalPtyProvider', () => { await provider.spawn({ cols: 80, rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' } }) } finally { if (savedCodexHome === undefined) { @@ -693,8 +694,12 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe') expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') - expect(spawnCall[2].env.WSLENV).toBe( - 'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' + expect(spawnCall[2].env.WSLENV?.split(':')).toEqual( + expect.arrayContaining([ + 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_HERMES_STARTUP_QUERY', + POWERLEVEL10K_WIZARD_DISABLE_ENV + ]) ) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 806dfdd5d24..90ccae11ea6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -57,6 +57,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -567,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider { // through Windows wsl.exe; non-default env vars need WSLENV import. addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR']) } + if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + // Why: the startup wrapper expands this only inside WSL; wsl.exe + // otherwise drops custom Windows environment variables. + addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } } else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) { // Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use // them. ORCA_CODEX_HOME must go too because shell-ready scripts restore diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d9bcf0cd17f..336a3882b3d 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -38,6 +38,7 @@ export function buildSourceControlAgentDeliveryPlan({ detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides, + terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell, platform: launchPlatform, isRemote }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index f31853e6117..f221944a8a4 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -23,6 +23,7 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => { }) import { + buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' @@ -633,3 +634,27 @@ describe('submitFolderWorkspaceCreate', () => { expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() }) }) + +describe('buildFolderWorkspaceLinkedStartupPlan', () => { + it('uses cmd quoting for configured arguments on local Windows', () => { + const plan = buildFolderWorkspaceLinkedStartupPlan({ + agent: 'hermes', + linkedWorkItem: { + provider: 'github', + type: 'issue', + number: 42, + title: 'Restore linked quick-create', + url: 'https://github.com/stablyai/orca/issues/42', + repoId: 'repo-1' + }, + note: '', + agentCmdOverrides: {}, + agentArgs: '--provider "value with space"', + platform: 'win32', + shell: 'cmd', + isRemote: false + }) + + expect(plan?.launchCommand).toBe('hermes --tui "--provider" "value with space"') + }) +}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 8732b166ec5..ee48eac66f2 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -17,6 +17,8 @@ import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' +import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { @@ -44,6 +46,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource runtimeEnvironmentId?: string | null @@ -61,7 +64,7 @@ export function getFolderWorkspaceAgentLaunchPlatform( return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM } -function buildFolderWorkspaceLinkedStartupPlan(args: { +export function buildFolderWorkspaceLinkedStartupPlan(args: { agent: TuiAgent linkedWorkItem: LinkedWorkItemSummary note: string @@ -69,6 +72,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs?: string | null agentEnv?: Record platform: NodeJS.Platform + shell?: AgentStartupShell isRemote: boolean }): AgentStartupPlan | null { const { prompt, draftPrompt } = resolveQuickCreateLinkedWorkItemPrompt( @@ -84,6 +88,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs: args.agentArgs, agentEnv: args.agentEnv, platform: args.platform, + shell: args.shell, isRemote: args.isRemote }) : null @@ -110,6 +115,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: { agentArgs: args.agentArgs, agentEnv: args.agentEnv, platform: args.platform, + shell: args.shell, isRemote: args.isRemote, allowEmptyPromptLaunch: true }) @@ -153,6 +159,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, createFolderWorkspace, @@ -168,6 +175,11 @@ export async function submitFolderWorkspaceCreate({ // Why: an SSH folder group runs the plain `orca` relay shim, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const launchIsRemote = Boolean(projectGroup.connectionId) + const launchShell = resolveLocalWindowsAgentStartupShell({ + platform: launchPlatform, + isRemote: launchIsRemote, + terminalWindowsShell + }) const startupPlan = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ @@ -178,6 +190,7 @@ export async function submitFolderWorkspaceCreate({ agentArgs, agentEnv, platform: launchPlatform, + shell: launchShell, isRemote: launchIsRemote }) : quickAgent @@ -188,6 +201,7 @@ export async function submitFolderWorkspaceCreate({ agentArgs, agentEnv, platform: launchPlatform, + shell: launchShell, isRemote: launchIsRemote, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index d1316b98678..922f1262830 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -22,6 +22,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -780,6 +781,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied to remote launch commands. const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false + const selectedRepoStartupShell = resolveLocalWindowsAgentStartupShell({ + platform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote, + terminalWindowsShell: settings?.terminalWindowsShell + }) const selectedRepoProjectId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null const selectedProjectId = selectedProjectGroup @@ -3344,6 +3350,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, + terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', runtimeEnvironmentId: folderTargetRuntimeEnvironmentId, @@ -3399,6 +3406,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.terminalWindowsShell, telemetrySource ] ) @@ -3598,6 +3606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote }) const shouldSeedInitialAgentStatus = @@ -3769,6 +3778,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepo, selectedRepoAgentLaunchPlatform, selectedRepoIsRemote, + selectedRepoStartupShell, selectedRepoIsGit, selectedRepoRequiresConnection, showProjectRequiredError, @@ -4015,6 +4025,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote }) @@ -4039,6 +4050,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, allowEmptyPromptLaunch: true }) @@ -4214,6 +4226,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepo, selectedRepoAgentLaunchPlatform, selectedRepoIsRemote, + selectedRepoStartupShell, selectedRepoIsGit, selectedRepoSettings, selectedRepoRequiresConnection, diff --git a/src/renderer/src/lib/agent-background-session-cleanup.ts b/src/renderer/src/lib/agent-background-session-cleanup.ts new file mode 100644 index 00000000000..dab1945534b --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-cleanup.ts @@ -0,0 +1,9 @@ +export function runBestEffortAgentBackgroundCleanups(...actions: (() => void)[]): void { + for (const action of actions) { + try { + action() + } catch { + // Preserve the launch/setup error that triggered cleanup. + } + } +} diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index 5b157525053..86cf3c82961 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -25,6 +25,7 @@ import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/tas import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' export type GitHubWorkItemBackgroundStoreSnapshot = { repos: readonly Repo[] @@ -43,6 +44,7 @@ export type GitHubWorkItemBackgroundStoreSnapshot = { | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' + | 'terminalWindowsShell' > > | null @@ -185,6 +187,11 @@ export function buildGitHubWorkItemStartupPlan(args: { // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const isRemote = repoIsRemote(repo) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) const draftLaunchPlan = draftPrompt ? buildAgentDraftLaunchPlan({ agent, @@ -193,6 +200,7 @@ export function buildGitHubWorkItemStartupPlan(args: { agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, + shell, isRemote }) : null @@ -215,6 +223,7 @@ export function buildGitHubWorkItemStartupPlan(args: { agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, + shell, isRemote, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 96f01753116..652fddb9bcf 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -22,6 +22,7 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const mockDispatchEvent = vi.fn() +const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ function expectStablePaneSpawn(): string { @@ -94,6 +95,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-launch-platform', () => ({ + getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo +})) + vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, subscribeToPtyExit: mockSubscribeToPtyExit @@ -108,6 +113,7 @@ describe('launchAgentBackgroundSession', () => { resetRemoteRuntimeTerminalMultiplexersForTests() clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') mockRuntimeEnvironmentTransportCall.mockImplementation( (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) @@ -492,6 +498,62 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('passes Hermes automation prompts through the native startup query', async () => { + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' }) + }) + ) + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + + it('uses the configured cmd shell for Windows Hermes background launches', async () => { + mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + Object.assign(state.settings, { terminalWindowsShell: 'cmd.exe' }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('powershell.exe -NoProfile -EncodedCommand'), + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' }) + }) + ) + }) + + it('forwards Hermes startup queries through SSH command transport', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'remote automation prompt' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + connectionId: 'ssh-1', + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) + }) + ) + }) + it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index ccdbd5fa16d..edb4c6d9f68 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -38,14 +38,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' - -function runBestEffortCleanup(action: () => void): void { - try { - action() - } catch { - // Preserve the launch/setup error that triggered cleanup. - } -} +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs @@ -80,6 +74,11 @@ export async function launchAgentBackgroundSession( // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only // `orca-ide` rename must not be applied for remote launches. const isRemote = repo ? repoIsRemote(repo) : false + const startupShell = resolveLocalWindowsAgentStartupShell({ + platform: launchPlatform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -92,6 +91,7 @@ export async function launchAgentBackgroundSession( agentArgs, agentEnv, platform: launchPlatform, + shell: startupShell, isRemote, allowEmptyPromptLaunch: !hasPrompt || isFollowupPath }) @@ -311,12 +311,11 @@ export async function launchAgentBackgroundSession( // Why: terminal creation and stream subscription are separate remote calls. // A failure between them must not strand an invisible runtime terminal. exitHandled = true - runBestEffortCleanup(unsubscribeExit) - runBestEffortCleanup(unsubscribeData) - runBestEffortCleanup(() => eagerPtyBuffer?.dispose()) - runBestEffortCleanup(() => sshStartupDelivery.clear()) - runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId)) - runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey)) + runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) + runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) + runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) + runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId)) + runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) if (ptyId) { try { if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) { @@ -330,7 +329,7 @@ export async function launchAgentBackgroundSession( // Best-effort close; retiring the invalid hidden tab must still proceed. } } - runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false })) + runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false })) throw error } } diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index d88cb3b7f84..a1cb9b6d3ed 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -55,6 +55,32 @@ describe('planSourceControlAgentActionLaunch', () => { expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") }) + it.each([ + { + terminalWindowsShell: 'cmd.exe', + expectedCommand: 'powershell.exe -NoProfile -EncodedCommand' + }, + { + terminalWindowsShell: 'git-bash', + expectedCommand: 'ORCA_HERMES_STARTUP_QUERY' + } + ])( + 'uses $terminalWindowsShell quoting for Hermes source-control prompts', + ({ terminalWindowsShell, expectedCommand }) => { + const result = planSourceControlAgentActionLaunch({ + agent: 'hermes', + commandInput: 'Review the change', + promptDelivery: 'auto-submit', + detectedAgents: ['hermes'], + platform: 'win32', + terminalWindowsShell + }) + + expect(result.ok && result.plan.launchCommand).toContain(expectedCommand) + expect(result.ok && result.plan.env?.ORCA_HERMES_STARTUP_QUERY).toBe('Review the change') + } + ) + it('rejects invalid per-action CLI arguments', () => { expect( planSourceControlAgentActionLaunch({ diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index c323688365a..c865f576ed9 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -9,6 +9,7 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' export type SourceControlLaunchPlanDelivery = | 'argv' @@ -36,6 +37,7 @@ export function planSourceControlAgentActionLaunch(args: { cmdOverrides?: Partial> agentArgs?: string | null platform?: NodeJS.Platform + terminalWindowsShell?: string | null /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean @@ -83,7 +85,12 @@ export function planSourceControlAgentActionLaunch(args: { const cmdOverrides = args.cmdOverrides ?? {} const platform = args.platform ?? CLIENT_PLATFORM const isRemote = args.isRemote ?? false - const shell = platform === 'win32' ? 'powershell' : 'posix' + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: args.terminalWindowsShell + }) ?? (platform === 'win32' ? 'powershell' : 'posix') const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) if (!plannedArgs.ok) { return { ok: false, error: plannedArgs.error } @@ -97,6 +104,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -108,6 +116,7 @@ export function planSourceControlAgentActionLaunch(args: { draft: trimmedInput, cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs }) @@ -130,6 +139,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -142,6 +152,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: '', cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: true @@ -153,6 +164,7 @@ export function planSourceControlAgentActionLaunch(args: { prompt: trimmedInput, cmdOverrides, platform, + shell, isRemote, agentArgs: args.agentArgs, allowEmptyPromptLaunch: false diff --git a/src/shared/agent-draft-platform-limit.ts b/src/shared/agent-draft-platform-limit.ts new file mode 100644 index 00000000000..7b94b5624e3 --- /dev/null +++ b/src/shared/agent-draft-platform-limit.ts @@ -0,0 +1,18 @@ +const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 + +export function inlineAgentDraftFitsPlatform(args: { + command: string + env?: Record + platform: NodeJS.Platform +}): boolean { + if (args.platform !== 'win32') { + return true + } + const envChars = Object.entries(args.env ?? {}).reduce( + (total, [key, value]) => total + key.length + value.length, + 0 + ) + // Why: Windows CreateProcess/env blocks have tight length ceilings. Large + // generated drafts should use the existing post-ready paste fallback. + return args.command.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS +} diff --git a/src/shared/hermes-startup-query.ts b/src/shared/hermes-startup-query.ts new file mode 100644 index 00000000000..79dd818f172 --- /dev/null +++ b/src/shared/hermes-startup-query.ts @@ -0,0 +1,203 @@ +import { + buildShellCommandFromArgv, + quoteStartupArg, + tokenizeStartupCommand, + type AgentStartupShell +} from './tui-agent-startup-shell' + +const QUERY_ENV_LIMIT = 24_000 +const QUERY_PLACEHOLDER = '__ORCA_HERMES_STARTUP_QUERY__' +const QUERY_ARG_PLACEHOLDER = `--query=${QUERY_PLACEHOLDER}` +const POSIX_QUERY_VARIABLE = '__orca_hermes_startup_query' +const POWERSHELL_QUERY_VARIABLE = 'orcaHermesStartupQuery' +const POWERSHELL_NATIVE_QUERY_VARIABLE = 'orcaHermesNativeQuery' + +export const ORCA_HERMES_STARTUP_QUERY_ENV = 'ORCA_HERMES_STARTUP_QUERY' + +function encodePowerShellCommand(command: string): string { + let bytes = '' + for (let index = 0; index < command.length; index += 1) { + const code = command.charCodeAt(index) + bytes += String.fromCharCode(code & 0xff, code >>> 8) + } + return btoa(bytes) +} + +function encodePosixEvalScript(command: string): string { + return Array.from( + new TextEncoder().encode(command), + (byte) => `\\0${byte.toString(8).padStart(3, '0')}` + ).join('') +} + +function tokenizeCommand(value: string, shell: AgentStartupShell): string[] | null { + const tokenized = tokenizeStartupCommand(value, shell) + return tokenized.ok && tokenized.tokens.length > 0 ? tokenized.tokens : null +} + +function isHermesExecutableToken(value: string): boolean { + const basename = value.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + return basename === 'hermes' || basename === 'hermes.exe' || basename === 'hermes.cmd' +} + +const HERMES_VALUELESS_FLAGS = new Set([ + '--tui', + '--cli', + '--verbose', + '-v', + '--quiet', + '-Q', + '--worktree', + '-w', + '--accept-hooks', + '--checkpoints', + '--yolo', + '--pass-session-id', + '--ignore-user-config', + '--ignore-rules', + '--safe-mode', + '--dev' +]) + +function findChatSubcommand(args: readonly string[]): number { + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === 'chat') { + return index + } + if (token.startsWith('-') && !token.includes('=') && !HERMES_VALUELESS_FLAGS.has(token)) { + index += 1 + } + } + return -1 +} + +function stripOrcaOwnedHermesArgs(args: readonly string[]): string[] { + const normalized: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === '--query' || token === '-q') { + index += 1 + continue + } + if ( + token.startsWith('--query=') || + token.startsWith('-q=') || + (token.startsWith('-q') && token.length > 2) || + token === '--tui' || + token === '--cli' + ) { + continue + } + normalized.push(token) + } + return normalized +} + +function normalizeHermesArgv( + baseArgv: string[], + configuredArgv: string[], + shell: AgentStartupShell +): string[] | null { + const executableCandidates: number[] = [] + for (let index = 0; index < baseArgv.length; index += 1) { + if (isHermesExecutableToken(baseArgv[index])) { + executableCandidates.push(index) + } + } + let anchoredExecutable: number | undefined + for (const index of executableCandidates) { + if (findChatSubcommand(baseArgv.slice(index + 1)) !== -1) { + // Wrapper arguments can also be named "hermes"; the final match is the executable. + anchoredExecutable = index + } + } + const executableIndex = anchoredExecutable ?? executableCandidates.at(-1) ?? -1 + if (executableIndex === -1) { + return null + } + let commandPrefix = baseArgv.slice(0, executableIndex + 1) + let assignmentCount = 0 + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(commandPrefix[assignmentCount] ?? '')) { + assignmentCount += 1 + } + if (assignmentCount > 0) { + if (shell !== 'posix') { + return null + } + commandPrefix = ['env', ...commandPrefix] + } + const baseArgs = baseArgv.slice(executableIndex + 1) + const chatIndex = findChatSubcommand(baseArgs) + const baseArgsWithoutChat = + chatIndex === -1 ? baseArgs : baseArgs.filter((_, index) => index !== chatIndex) + const configuredChatIndex = findChatSubcommand(configuredArgv) + const configuredArgsWithoutChat = + configuredChatIndex === -1 + ? configuredArgv + : configuredArgv.filter((_, index) => index !== configuredChatIndex) + return [ + ...commandPrefix, + 'chat', + QUERY_ARG_PLACEHOLDER, + ...stripOrcaOwnedHermesArgs(baseArgsWithoutChat), + ...stripOrcaOwnedHermesArgs(configuredArgsWithoutChat), + '--tui' + ] +} + +function buildQueryCommand(argv: string[], shell: AgentStartupShell): string { + if (shell !== 'posix') { + const invocation = buildShellCommandFromArgv(argv, 'powershell').replace( + quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'powershell'), + `"--query=$${POWERSHELL_NATIVE_QUERY_VARIABLE}"` + ) + // Why: startup prompts must not remain exported to Hermes tools; the + // long-lived parent shell retains the transport env for compatibility. + // PowerShell 5 needs Windows-native quote escaping before building child argv. + const script = `$${POWERSHELL_QUERY_VARIABLE} = $env:${ORCA_HERMES_STARTUP_QUERY_ENV}; $${POWERSHELL_NATIVE_QUERY_VARIABLE} = $${POWERSHELL_QUERY_VARIABLE} -replace '(\\\\*)"', '$1$1\\"'; Remove-Item Env:${ORCA_HERMES_STARTUP_QUERY_ENV} -ErrorAction SilentlyContinue; ${invocation}` + return `powershell.exe -NoProfile -EncodedCommand ${encodePowerShellCommand(script)}` + } + const invocation = buildShellCommandFromArgv(argv, 'posix').replace( + quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'posix'), + `"--query=\${${POSIX_QUERY_VARIABLE}}"` + ) + const encodedInvocation = encodePosixEvalScript(invocation) + // Why: a fixed single-quote-safe wrapper parses in POSIX shells and pwsh; + // the dynamic argv is decoded only after entering the known `sh` grammar. + const script = `${POSIX_QUERY_VARIABLE}="\${${ORCA_HERMES_STARTUP_QUERY_ENV}}"; unset ${ORCA_HERMES_STARTUP_QUERY_ENV}; eval "$(printf %b "${encodedInvocation}")"` + return `sh -c ${quoteStartupArg(script, 'posix')}` +} + +export function planHermesStartupQuery(args: { + baseCommand: string + agentArgs?: string | null + prompt: string + agentEnv?: Record | null + platform: NodeJS.Platform + shell: AgentStartupShell + isRemote?: boolean +}): { command: string; env: Record } | null { + const baseArgv = tokenizeCommand(args.baseCommand, args.shell) + const configuredArgv = args.agentArgs?.trim() ? tokenizeCommand(args.agentArgs, args.shell) : [] + if (!baseArgv || !configuredArgv) { + return null + } + const argv = normalizeHermesArgv(baseArgv, configuredArgv, args.shell) + if (!argv) { + return null + } + const command = buildQueryCommand(argv, args.shell) + const env = { ...args.agentEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]: args.prompt } + const envSize = Object.entries(env).reduce((total, [key, value]) => { + if (args.platform === 'win32') { + return total + key.length + value.length + 2 + } + return total + new TextEncoder().encode(`${key}=${value}`).byteLength + 1 + }, 0) + const commandSize = + args.platform === 'win32' ? command.length : new TextEncoder().encode(command).byteLength + // Why: WSL plans execute as Linux but cross the Windows environment block; + // the conservative shared bound is safe for every transport host. + return commandSize + envSize <= QUERY_ENV_LIMIT ? { command, env } : null +} diff --git a/src/shared/hermes-startup-query.windows.test.ts b/src/shared/hermes-startup-query.windows.test.ts new file mode 100644 index 00000000000..fd0a007cf85 --- /dev/null +++ b/src/shared/hermes-startup-query.windows.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { planHermesStartupQuery } from './hermes-startup-query' + +const windowsIt = process.platform === 'win32' ? it : it.skip + +windowsIt('preserves the startup query and spaced custom args in native Windows argv', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-hermes-startup-query-')) + const capturePath = join(tempDir, 'argv.json') + const hermesPath = join(tempDir, 'hermes.exe') + const prompt = [ + 'Return the marker with quoted="alpha beta" and unicode=雪🚀.', + 'Keep %PATH% literal.', + '--version' + ].join('\n') + + try { + // Why: the Node executable is a real native Windows process, so this catches + // PowerShell argument marshalling bugs that generated-script assertions miss. + copyFileSync(process.execPath, hermesPath) + writeFileSync( + join(tempDir, 'chat'), + `require('node:fs').writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)))` + ) + const plan = planHermesStartupQuery({ + baseCommand: `"${hermesPath}"`, + agentArgs: '--yolo --source "Orca automation validation"', + prompt, + platform: 'win32', + shell: 'powershell' + }) + const encodedCommand = plan?.command.match(/-EncodedCommand\s+(\S+)/)?.[1] + + expect(encodedCommand).toBeDefined() + execFileSync('powershell.exe', ['-NoProfile', '-EncodedCommand', encodedCommand!], { + cwd: tempDir, + env: { ...process.env, ...plan?.env } + }) + expect(JSON.parse(readFileSync(capturePath, 'utf8'))).toEqual([ + `--query=${prompt}`, + '--yolo', + '--source', + 'Orca automation validation', + '--tui' + ]) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) diff --git a/src/shared/sleeping-agent-launch-config.ts b/src/shared/sleeping-agent-launch-config.ts new file mode 100644 index 00000000000..b03f6bba961 --- /dev/null +++ b/src/shared/sleeping-agent-launch-config.ts @@ -0,0 +1,15 @@ +import type { SleepingAgentLaunchConfig } from './agent-session-resume' + +export function buildSleepingAgentLaunchConfig(args: { + agentCommand?: string | null + agentArgs?: string | null + agentEnv?: Record | null +}): SleepingAgentLaunchConfig { + return { + ...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}), + agentArgs: args.agentArgs ?? '', + // Why: startup env may include prompt transport or pane identity values; + // durable resume state is limited to Orca-managed agent inputs. + agentEnv: args.agentEnv ? { ...args.agentEnv } : {} + } +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 29a03d14bc4..6e5f5d8295a 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -6,6 +6,7 @@ export type AgentPromptInjectionMode = | 'flag-prompt' | 'flag-prompt-interactive' | 'flag-interactive' + | 'hermes-query' | 'stdin-after-start' export type DraftPasteReadySignal = @@ -325,7 +326,9 @@ export const TUI_AGENT_CONFIG: Record = { // `--tui` starts the full-screen agent UI Orca is designed to host. launchCmd: 'hermes --tui', expectedProcess: 'hermes', - promptInjectionMode: 'stdin-after-start' + // Why: Hermes owns prompt delivery through its startup-query contract, + // which submits only after the TUI composer and session are ready. + promptInjectionMode: 'hermes-query' }, openclaw: { detectCmd: 'openclaw', diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index db1cee17017..8bdb5465845 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -2,6 +2,71 @@ import { tokenizeCustomCommandTemplate } from './commit-message-prompt' export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' +export type StartupCommandTokens = { ok: true; tokens: string[] } | { ok: false; error: string } + +function tokenizeWindowsStartupCommand( + value: string, + shell: Exclude +): StartupCommandTokens { + const tokens: string[] = [] + let token = '' + let quote: "'" | '"' | null = null + let tokenStarted = false + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + const escape = shell === 'cmd' ? '^' : '`' + if (char === escape && index + 1 < value.length) { + token += value[index + 1] + tokenStarted = true + index += 1 + continue + } + if (quote) { + if (char === quote) { + if (shell === 'powershell' && quote === "'" && value[index + 1] === "'") { + token += "'" + index += 1 + } else { + quote = null + } + } else { + token += char + } + tokenStarted = true + continue + } + if (char === "'" || char === '"') { + quote = char + tokenStarted = true + } else if (/\s/.test(char)) { + if (tokenStarted) { + tokens.push(token) + token = '' + tokenStarted = false + } + } else { + token += char + tokenStarted = true + } + } + if (quote) { + return { ok: false, error: 'Unclosed quote in command template.' } + } + if (tokenStarted) { + tokens.push(token) + } + return { ok: true, tokens } +} + +export function tokenizeStartupCommand( + value: string, + shell: AgentStartupShell +): StartupCommandTokens { + return shell === 'posix' + ? tokenizeCustomCommandTemplate(value) + : tokenizeWindowsStartupCommand(value, shell) +} + export function resolveStartupShell( platform: NodeJS.Platform, shell?: AgentStartupShell @@ -54,7 +119,7 @@ export function planAgentCliArgsSuffix( if (!trimmed) { return { ok: true, suffix: '' } } - const tokenized = tokenizeCustomCommandTemplate(trimmed) + const tokenized = tokenizeStartupCommand(trimmed, shell) if (!tokenized.ok) { return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` } } diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 71f455fbc57..22ffdcdc15f 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -3,12 +3,42 @@ import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, buildAgentStartupPlan, - buildShellCommandFromArgv + buildShellCommandFromArgv, + planAgentCliArgsSuffix } from './tui-agent-startup' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +function unwrapPosixShellScript(command: string | undefined): string { + const tokenized = tokenizeStartupCommand(command ?? '', 'posix') + expect(tokenized.ok).toBe(true) + const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' + const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] + if (!encoded) { + return wrapper + } + const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +function unwrapPowerShellScript(command: string | undefined): string { + const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} describe('tui agent startup plans', () => { + it.each(['powershell', 'cmd'] as const)( + 'keeps the established invalid-quote error on %s', + (shell) => { + expect(planAgentCliArgsSuffix('--model "unterminated', shell)).toEqual({ + ok: false, + error: 'CLI arguments are invalid: Unclosed quote in command template.' + }) + } + ) + it('uses POSIX quoting when the target shell is Linux', () => { const plan = buildAgentStartupPlan({ agent: 'claude', @@ -106,6 +136,277 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("codex '--version'") }) + it.each([ + { platform: 'linux' as const, shell: 'posix' as const }, + { platform: 'win32' as const, shell: 'powershell' as const }, + { platform: 'win32' as const, shell: 'cmd' as const } + ])('delivers multiline Hermes queries through a child-only expansion on $shell', (testCase) => { + const prompt = 'first line\nsecond "quoted" line with %PATH%' + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + agentArgs: '--yolo', + platform: testCase.platform, + shell: testCase.shell + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.followupPrompt).toBeNull() + expect(plan?.launchConfig.agentCommand).toBe('hermes --tui') + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + const script = + testCase.shell === 'posix' + ? unwrapPosixShellScript(plan?.launchCommand) + : unwrapPowerShellScript(plan?.launchCommand) + expect(script).toContain("'hermes' 'chat'") + expect(script).toContain('--query=') + expect(testCase.shell === 'posix' ? plan?.launchCommand : script).toContain( + 'ORCA_HERMES_STARTUP_QUERY' + ) + expect(script).toContain("'--yolo' '--tui'") + expect(script).toContain( + testCase.shell === 'posix' + ? '--query=${__orca_hermes_startup_query}' + : 'Remove-Item Env:ORCA_HERMES_STARTUP_QUERY' + ) + if (testCase.shell === 'posix') { + expect(plan?.launchCommand).toContain('unset ORCA_HERMES_STARTUP_QUERY') + } + }) + + it('uses a sh invocation that POSIX-host PowerShell can parse', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toMatch(/^sh -c /) + expect(plan?.launchCommand).toMatch(/\\0[0-7]{3}/) + expect(plan?.launchCommand).not.toContain("'sh' '-c'") + expect(plan?.launchCommand).not.toContain("'\\''") + }) + + it('moves Hermes command override flags after the chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run privately', + cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, + agentArgs: '--yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + }) + + it.each([ + { + shell: 'powershell' as const, + override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', + expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" + }, + { + shell: 'cmd' as const, + override: 'C:\\Tools\\hermes.exe --tui', + expected: "& 'C:\\Tools\\hermes.exe' 'chat'" + } + ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: testCase.override }, + platform: 'win32', + shell: testCase.shell + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) + }) + + it('removes a configured duplicate chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, + agentArgs: '--provider copilot chat --yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--provider' 'copilot' '--yolo'") + }) + + it('preserves an option value named chat', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --profile chat --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") + }) + + it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'automation prompt', + cmdOverrides: { hermes: 'hermes --query override --cli' }, + agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/--query=/g)).toHaveLength(1) + expect(script).not.toContain('override') + expect(script).not.toContain("'--cli'") + expect(script.match(/'--tui'/g)).toHaveLength(1) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') + }) + + it('preserves wrapper tokens before the Hermes executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") + }) + + it('selects the final Hermes executable token in a wrapper', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'sudo' '-u' 'hermes' 'hermes' 'chat'" + ) + }) + + it('selects the wrapped executable when the wrapper and command both name Hermes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") + expect(script.match(/'chat'/g)).toHaveLength(1) + }) + + it('does not mistake a Hermes option value for a wrapped executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--resume' 'hermes'") + }) + + it('preserves POSIX environment-assignment command prefixes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" + ) + }) + + it('rejects a Hermes command override with no identifiable executable', () => { + expect( + buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'custom-agent --tui' }, + platform: 'linux' + }) + ).toBeNull() + }) + + it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'x'.repeat(24_000), + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toBeNull() + }) + + it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( + 'keeps a complex cmd Hermes query out of command text: %s', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + } + ) + + it('uses the Windows remote default shell for SSH Hermes queries', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run remotely', + cmdOverrides: {}, + platform: 'win32', + isRemote: true + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( + "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" + ) + }) + + it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '界'.repeat(50_000), + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toBeNull() + }) + + it('keeps empty Hermes launches on the interactive TUI command', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('hermes --tui') + expect(plan?.followupPrompt).toBeNull() + }) + it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 924108f7a19..6e3a334dbc1 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -15,10 +15,11 @@ import { } from './tui-agent-startup-shell' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' +import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' +import { planHermesStartupQuery } from './hermes-startup-query' +import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './types' -const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 - export type AgentStartupPlan = { agent: TuiAgent launchCommand: string @@ -54,20 +55,6 @@ function resolveBaseCommand(args: { return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } } -function buildSleepingAgentLaunchConfig(args: { - agentCommand?: string | null - agentArgs?: string | null - agentEnv?: Record | null -}): SleepingAgentLaunchConfig { - return { - ...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}), - agentArgs: args.agentArgs ?? '', - // Why: startupPlan.env may include prompt transport or pane identity env; the - // durable resume snapshot is limited to Orca-managed agent env inputs. - agentEnv: args.agentEnv ? { ...args.agentEnv } : {} - } -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -85,12 +72,13 @@ export function buildAgentStartupPlan(args: { const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] + const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) const baseCommand = resolveBaseCommand({ agent, cmdOverrides, platform, shell, - agentArgs: args.agentArgs, + agentArgs: usesQuery ? null : args.agentArgs, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -141,6 +129,31 @@ export function buildAgentStartupPlan(args: { } } + if (config.promptInjectionMode === 'hermes-query') { + const queryPlan = planHermesStartupQuery({ + baseCommand: baseCommand.command, + agentArgs: args.agentArgs, + prompt: trimmedPrompt, + agentEnv: args.agentEnv, + platform, + shell, + isRemote: args.isRemote + }) + if (!queryPlan) { + return null + } + return { + agent, + // Why: Hermes owns readiness and submission for `chat --query`; Orca + // only bounds and quotes the native invocation before starting the TUI. + launchCommand: queryPlan.command, + expectedProcess: config.expectedProcess, + followupPrompt: null, + launchConfig, + ...(queryPlan.env ? { env: queryPlan.env } : {}) + } + } + if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, @@ -233,22 +246,6 @@ export type AgentDraftLaunchPlan = { startupCommandDelivery?: StartupCommandDelivery } -function inlineDraftPlanFitsPlatform( - plan: AgentDraftLaunchPlan, - platform: NodeJS.Platform -): boolean { - if (platform !== 'win32') { - return true - } - const envChars = Object.entries(plan.env ?? {}).reduce( - (total, [key, value]) => total + key.length + value.length, - 0 - ) - // Why: Windows CreateProcess/env blocks have tight length ceilings. Large - // generated drafts should use the existing post-ready paste fallback. - return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS -} - export function buildAgentDraftLaunchPlan(args: { agent: TuiAgent draft: string @@ -304,7 +301,10 @@ export function buildAgentDraftLaunchPlan(args: { env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } - if (!plan || !inlineDraftPlanFitsPlatform(plan, platform)) { + if ( + !plan || + !inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform }) + ) { return null } return plan From 6e3ebf534c968ba3e904860509296779f6582699 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:02:29 -0700 Subject: [PATCH 066/138] fix(terminal): arbitrate active desktop viewport ownership (#8252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): let remote desktop viewers own the shared PTY width A remote (relay/shared-control) desktop viewer resizes the host source PTY to its own width, but was never registered as a width owner. So the host's own fit cascade (window resize, split drag, tab reveal, "+"-new-tab re-render) freely resized the viewed PTY back to the host-local width with no signal to the viewer. The viewer kept its narrower grid while the host streamed wider alt-screen frames -> cell-layout garble ("porridge") until a manual resize. The mobile "presence lock" already solves this shape for phones by suppressing the host's pty:resize while the phone drives. This does the same for remote desktop viewers WITHOUT joining the mobile driver state machine (a viewer needs only resize suppression, not input lock / phone-fit / driver banners), and it routes every PTY geometry change through the existing enqueueLayout/applyLayout serialization path rather than resizing the PTY ad hoc. Design: - Registry `remoteDesktopViewers: Map>`, keyed per SUBSCRIPTION so duplicate streams of one client cannot release each other. isPtyResizeDrivenRemotely() = mobile driver OR any viewer present; pty:resize (host fit cascade) bails on it. INPUT is never locked (shared control: host and viewers can both type), unlike mobile. - New internal layout target { kind: 'remote-desktop' } in applyLayout: clears terminalFitOverrides like 'desktop', resizes only when dims changed, emits no fit-override notifications, and does not call onExternalPtyResize (so a viewer's width never pollutes desktop restore state). enqueueLayout remains the sole serialized writer of PTY dimensions. - Smallest client wins: applyRemoteDesktopLayout() sizes the PTY to the SMALLEST attached viewer, so viewers with different screen sizes never overflow the narrowest grid. - No snapshot/replay race: a viewport that arrives while the initial scrollback snapshot is being serialized is buffered and applied as ONE serialized resize before serialization, so a mid-repaint alt screen is never baked into the snapshot. - Reclaim on detach: when the last viewer leaves, applyRemoteDesktopLayout resizes the PTY back to the host's own width via enqueueLayout({kind:'desktop'}), so the host reflows to full width without a manual window jiggle. The host width is captured at the pty:resize suppression point (the host's own resize attempts) — a source a remote viewport never pollutes. - Mobile coexistence: a phone outranks a viewer; when the phone leaves, the mobile-release paths call applyRemoteDesktopLayout so a surviving viewer keeps the PTY, else the host reclaims. Verified live (two local desktop instances): correct render on connect, no garble on viewer-window resize (synchronous), and host reclaims full width on disconnect. Unit tests cover the registry lifecycle, smallest-client-wins, reclaim-via-layout, and mobile coexistence. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Codex * fix(terminal): address remote-desktop PTY width review findings Resolves four CodeRabbit findings on the remote-desktop viewer width ownership change, plus a round-trip race surfaced while fixing the leak: - Leak: the one-shot terminal.updateViewport RPC has no disconnect hook, so it must never create a width floor (nothing releases it, pinning the host at a stale width). Add refreshRemoteDesktopViewer, which only refreshes floors the client already owns via its stream subscription (matched by clientId), mirroring the mobile updateMobileViewport no-op-without-subscription invariant. The one-shot handler now refreshes instead of registering. - Round-trip replay: with the one-shot fallback now refresh-only, a resize landing during the subscribe round-trip (connected, stream not yet current) was dropped. The transport now replays the latest viewport over the stream once it becomes current. - Reclaim: keep the host reclaim target unless the reclaim resize actually landed (result.ok), so a failed reclaim can retry against true host geometry instead of a stale remote width. - Snapshot drain: drain pendingRemoteDesktopViewport in the sendRequestedSnapshot finally block; a viewer resize parked during a SnapshotRequest buffering window was otherwise dropped until the next resize. - Key scope: scope the multiplex width-floor key by connectionId so two connections reusing the same client-local streamId can't overwrite or release each other's floor. Tests: refresh-never-creates-floor, reclaim-target-retained-on-failed-resize, SnapshotRequest parked-resize drain, connection-scoped key assertions, and a round-trip replay race test (verified to fail without the flush). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(terminal): arbitrate active desktop viewport ownership Co-authored-by: Orca * test(terminal): expect viewport claim capability Co-authored-by: Orca * fix(terminal): preserve input across ownership teardown Co-authored-by: Orca * test(terminal): keep subscribe buffer coverage within lint limit Co-authored-by: Orca --------- Co-authored-by: shady Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Codex Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 113 +++- src/main/ipc/pty.ts | 77 ++- src/main/ipc/runtime.ts | 7 +- src/main/runtime/orca-runtime.ts | 496 ++++++++++++++-- .../runtime/remote-desktop-driver.test.ts | 517 ++++++++++++++++ src/main/runtime/rpc/methods/terminal.ts | 555 ++++++++++++++--- src/main/runtime/rpc/streaming.test.ts | 3 + .../runtime/rpc/terminal-multiplex.test.ts | 559 +++++++++++++++++- src/main/runtime/rpc/terminal-send.test.ts | 40 ++ .../rpc/terminal-subscribe-buffer.test.ts | 28 +- src/preload/api-types.ts | 5 +- src/preload/index.ts | 14 +- .../components/terminal-pane/TerminalPane.tsx | 13 +- .../mobile-driver-overlay-visibility.test.ts | 11 + .../mobile-driver-overlay-visibility.ts | 8 + .../terminal-pane/pty-connection.test.ts | 71 ++- .../terminal-pane/pty-connection.ts | 93 ++- .../terminal-pane/pty-transport-types.ts | 9 +- .../components/terminal-pane/pty-transport.ts | 17 +- .../remote-desktop-viewport-claim.test.ts | 60 ++ .../remote-desktop-viewport-claim.ts | 23 + .../remote-runtime-pty-transport.test.ts | 306 +++++++++- .../remote-runtime-pty-transport.ts | 177 +++++- src/renderer/src/hooks/useIpcEvents.ts | 2 +- .../pane-manager/mobile-fit-overrides.test.ts | 22 +- .../lib/pane-manager/mobile-fit-overrides.ts | 36 +- .../lib/pane-manager/pane-tree-ops.test.ts | 16 + .../remote-runtime-terminal-multiplexer.ts | 28 +- .../runtime/runtime-terminal-stream.test.ts | 12 +- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/terminal-stream-protocol.ts | 8 +- 31 files changed, 3070 insertions(+), 257 deletions(-) create mode 100644 src/main/runtime/remote-desktop-driver.test.ts create mode 100644 src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2b405685c45..751245fb62d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4075,12 +4075,13 @@ describe('registerPtyHandlers', () => { applied: { cols: number; rows: number } | null resize?: (cols: number, rows: number) => void getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> - }): void { + }): ReturnType { + const write = vi.fn() setLocalPtyProvider({ spawn: vi.fn(async (opts: { sessionId?: string }) => ({ id: opts.sessionId ?? 'daemon-pty' })), - write: vi.fn(), + write, resize: vi.fn(args.resize ?? (() => {})), getAppliedSize: vi.fn(args.getAppliedSize ?? (async () => args.applied)), kill: vi.fn(), @@ -4090,6 +4091,7 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + return write } const resizeListener = (): ((event: unknown, args: unknown) => void) => { @@ -4217,6 +4219,113 @@ describe('registerPtyHandlers', () => { expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() }) + + it('suppresses the host fit cascade while a remote viewer drives the width', async () => { + const resizeSpy = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + // The whole point of the fix: a PTY with a remote viewer reports true, + // even though the presence-lock driver state stays idle/desktop. + isRemoteDesktopResizeDriven: vi.fn(() => true), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + recordRemoteDesktopHostReclaimTarget: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + resizeSpy.mockClear() + + // Host's own safeFit tries to widen the viewed PTY back to its window. + resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + + // It must not reach the PTY while the viewer owns the width. + expect(resizeSpy).not.toHaveBeenCalled() + expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48) + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) + + it('lets trusted host activity reclaim remote viewport ownership', () => { + const claimRemoteDesktopHost = vi.fn().mockResolvedValue(true) + const runtime = { + setPtyController: vi.fn(), + claimRemoteDesktopHost + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport') + const claimListener = call?.[1] as + | ((event: unknown, args: { id: string; cols: number; rows: number }) => void) + | undefined + expect(claimListener).toBeTypeOf('function') + + claimListener?.(mainWindowIpcEvent, { id: 'pty-1', cols: 125, rows: 48 }) + + expect(claimRemoteDesktopHost).toHaveBeenCalledWith('pty-1', 125, 48) + }) + + it('does not forward host input when viewport reclaim fails', async () => { + const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + claimRemoteDesktopHost: vi.fn().mockResolvedValue(false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + const claim = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport') + const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write') + + claim?.[1](mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + writeEvent?.[1](mainWindowIpcEvent, { id, data: 'x' }) + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + }) + + it('does not populate the remote reclaim cache when only a phone drives', async () => { + const resizeSpy = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'mobile', clientId: 'phone-A' })), + isRemoteDesktopResizeDriven: vi.fn(() => false), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + recordRemoteDesktopHostReclaimTarget: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + resizeSpy.mockClear() + + resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 }) + + expect(resizeSpy).not.toHaveBeenCalled() + expect(runtime.recordRemoteDesktopHostReclaimTarget).not.toHaveBeenCalled() + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 893a65e4c65..4dcc451324a 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4441,6 +4441,7 @@ export function registerPtyHandlers( } type PtyWritePayload = { id: string; data: string } + type PtyViewportClaimPayload = { id: string; cols: number; rows: number } const isPtyWritePayload = (value: unknown): value is PtyWritePayload => typeof value === 'object' && @@ -4449,6 +4450,18 @@ export function registerPtyHandlers( (value as { id: string }).id.length > 0 && typeof (value as { data?: unknown }).data === 'string' + const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { cols?: unknown }).cols === 'number' && + Number.isFinite((value as { cols: number }).cols) && + typeof (value as { rows?: unknown }).rows === 'number' && + Number.isFinite((value as { rows: number }).rows) && + (value as { cols: number }).cols > 0 && + (value as { rows: number }).rows > 0 + const isPtyWriteEventFromMainWindow = ( event: IpcMainEvent | IpcMainInvokeEvent, mainWebContents: WebContents @@ -4512,17 +4525,55 @@ export function registerPtyHandlers( } } + const hostViewportClaimTails = new Map>() + ipcMain.on('pty:write', (event, args: unknown) => { if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return } + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + return + } writePtyInput(args) }) ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return false } - return writePtyInputAccepted(args) + const claimTail = hostViewportClaimTails.get(args.id) + return claimTail + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) + : writePtyInputAccepted(args) + }) + + ipcMain.removeAllListeners('pty:claimViewport') + ipcMain.on('pty:claimViewport', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !runtime || + !isPtyViewportClaimPayload(args) + ) { + return + } + const prior = hostViewportClaimTails.get(args.id) + // Why: two panes can mirror one PTY. Never let a later no-op claim replace + // the in-flight resize that the following host input must await. + const claim = ( + prior + ? prior.then( + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ).catch(() => false) + hostViewportClaimTails.set(args.id, claim) + void claim.then(() => { + if (hostViewportClaimTails.get(args.id) === claim) { + hostViewportClaimTails.delete(args.id) + } + }) }) // Why: resize is fire-and-forget — the renderer doesn't need a reply. @@ -4538,14 +4589,22 @@ export function registerPtyHandlers( if (runtime?.isResizeSuppressed()) { return } - // Why: presence-lock defense-in-depth. While mobile is driving, - // desktop-side resizes (auto-fit on window resize, split drag) must - // not reach the PTY. The renderer guard checks the driver state too, - // but this is the load-bearing layer because the renderer mirror lags - // by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles - // the safeFit cascade after take-back; this driver check handles the - // ongoing locked state. See docs/mobile-presence-lock.md. - if (runtime?.getDriver(args.id).kind === 'mobile') { + // Why: presence-lock defense-in-depth. While a phone OR a remote desktop + // viewer drives the PTY width, the host's own desktop-side resizes + // (auto-fit on window resize, split drag, tab reveal, "+"-new-tab + // re-render) must not reach the PTY — otherwise they overwrite the remote + // viewer's grid and its alt-screen TUI garbles ("porridge"). The renderer + // guard checks the driver state too, but this is the load-bearing layer + // because the renderer mirror lags by one IPC hop. Note: BOTH guards apply + // — isResizeSuppressed handles the safeFit cascade after take-back; this + // driver check handles the ongoing locked state. See + // docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } return } const provider = tryGetProviderForPty(args.id) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e2805fd2862..3b9a8719afc 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -48,7 +48,12 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', - (): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => { + (): { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' + cols: number + rows: number + }[] => { const overrides = runtime.getAllTerminalFitOverrides() return Array.from(overrides.entries()).map(([ptyId, override]) => ({ ptyId, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2ef3dfb0e10..84f1954d194 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1378,7 +1378,7 @@ type RuntimeNotifier = { resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, - mode: 'mobile-fit' | 'desktop-fit', + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void @@ -2062,6 +2062,7 @@ export type DriverState = RuntimeTerminalDriverState export type PtyLayoutTarget = | { kind: 'desktop'; cols: number; rows: number } | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + | { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string } // Why: authoritative layout state with monotonic seq. Bumped on every // applyLayout success; emitted on mobile subscribe-stream events so clients @@ -2180,7 +2181,13 @@ export class OrcaRuntimeService { // invoked from resizeForClient and onClientDisconnected/onPtyExit. private fitOverrideListeners = new Map< string, - Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> + Set< + (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + > >() private driverListeners = new Map void>>() private subscriptionCleanups = new Map void>() @@ -2329,6 +2336,20 @@ export class OrcaRuntimeService { private currentDriver = new Map() private currentBrowserDriver = new Map() + // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by + // subscription, not client, because one client can open duplicate streams and + // each stream must release only the width floor it registered. + private remoteDesktopViewers = new Map< + string, + Map + >() + private remoteDesktopOwners = new Map() + private remoteDesktopActivity = 0 + private remoteDesktopHostReclaimTargets = new Map() + // Why: a completed host reclaim must not consume the cache if a newer + // viewer mutation landed while that serialized layout was in flight. + private remoteDesktopViewerRevisions = new Map() + // Why: resubscribe-grace window. When the last mobile subscriber for a // PTY unsubscribes, we hold the driver=mobile{clientId} state and the // inner-map record open for ~250ms. If the same (ptyId, clientId) @@ -6503,7 +6524,11 @@ export class OrcaRuntimeService { subscribeToFitOverrideChanges( ptyId: string, - listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void + listener: (event: { + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void ): () => void { return addListenerToMap(this.fitOverrideListeners, ptyId, listener) } @@ -6514,7 +6539,7 @@ export class OrcaRuntimeService { private notifyFitOverrideListeners( ptyId: string, - mode: 'mobile-fit' | 'desktop-fit', + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void { @@ -7791,11 +7816,26 @@ export class OrcaRuntimeService { return this.terminalFitOverrides.get(ptyId) ?? null } - getAllTerminalFitOverrides(): Map { - const result = new Map() + getAllTerminalFitOverrides(): Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + > { + const result = new Map< + string, + { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } + >() for (const [ptyId, override] of this.terminalFitOverrides) { result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) } + for (const [ptyId] of this.remoteDesktopOwners) { + if (result.has(ptyId)) { + continue + } + const size = this.getTerminalSize(ptyId) + if (size) { + result.set(ptyId, { mode: 'remote-desktop-fit', ...size }) + } + } return result } @@ -7876,7 +7916,16 @@ export class OrcaRuntimeService { // at phone dims after the phone disconnects; the desktop banner's // Restore button is the explicit return path. See // docs/mobile-fit-hold.md. - if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } // Use the soft-leaver's snapshot baseline as a hint, falling // through to resolveDesktopRestoreTarget for missing values. const fallback = this.resolveDesktopRestoreTarget(ptyId) @@ -7915,7 +7964,16 @@ export class OrcaRuntimeService { for (const { ptyId, baseline } of ptysToRestore) { const cur = this.layouts.get(ptyId) // Why: Indefinite hold gate — see soft-leaver branch above. - if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.hasRemoteDesktopViewers(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.setDriver(ptyId, { kind: 'idle' }) + void this.applyRemoteDesktopLayout(ptyId) + continue + } const fallback = this.resolveDesktopRestoreTarget(ptyId) const cols = baseline?.cols ?? fallback.cols const rows = baseline?.rows ?? fallback.rows @@ -8044,6 +8102,10 @@ export class OrcaRuntimeService { this.currentDriver.delete(ptyId) this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) } + this.remoteDesktopViewers.delete(ptyId) + this.remoteDesktopOwners.delete(ptyId) + this.remoteDesktopHostReclaimTargets.delete(ptyId) + this.remoteDesktopViewerRevisions.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) const pty = this.ptysById.get(ptyId) @@ -8099,6 +8161,326 @@ export class OrcaRuntimeService { } } + // Why: the host's own fit cascade (window resize, split drag, tab reveal, + // "+"-new-tab re-render) must not resize a PTY whose width a remote client + // owns — that is the remote "porridge" bug. True while a phone (mobile driver) + // OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated + // here (see the `writePtyInput` mobile-only checks): shared-control desktop + // viewers may still type alongside the host. + // Note: this is intentionally NOT a driver kind. An active remote viewer needs + // only resize suppression, not the mobile driver machinery (input lock, + // phone-fit, driver-change banners), so it lives in its own registry and does + // not perturb the presence-lock state machine. It also coexists with mobile: + // while a phone drives, the registry still suppresses host resize, and when + // the phone leaves the surviving viewer keeps the PTY suppressed. + isPtyResizeDrivenRemotely(ptyId: string): boolean { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + return this.isRemoteDesktopResizeDriven(ptyId) + } + + isRemoteDesktopResizeDriven(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) + } + + isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { + return this.remoteDesktopOwners.get(ptyId) === subscriptionKey + } + + getRemoteDesktopFitHold( + ptyId: string, + subscriptionKey: string + ): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } { + const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 } + return { + mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey) + ? 'desktop-fit' + : 'remote-desktop-fit', + ...size + } + } + + private hasRemoteDesktopViewers(ptyId: string): boolean { + const viewers = this.remoteDesktopViewers.get(ptyId) + return viewers !== undefined && viewers.size > 0 + } + + private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null { + const owner = this.remoteDesktopOwners.get(ptyId) + return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null + } + + private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } { + const target = this.remoteDesktopHostReclaimTargets.get(ptyId) + if (target) { + return target + } + // Why: a viewer can join while a phone owns the actual PTY size. The + // mobile restore chain retains the pre-phone desktop geometry; current + // PTY size alone would incorrectly capture the phone grid as host truth. + return this.resolveDesktopRestoreTarget(ptyId) + } + + private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void { + if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set( + ptyId, + this.resolveRemoteDesktopHostReclaimTarget(ptyId) + ) + } + } + + recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void { + // Why: phone presence also suppresses host resize, but must not seed the + // separate remote-viewer cache when no desktop stream owns a width floor. + if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) { + return + } + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } + + private hasRemoteDesktopLayoutState(ptyId: string): boolean { + return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId) + } + + private bumpRemoteDesktopViewerRevision(ptyId: string): number { + const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1 + this.remoteDesktopViewerRevisions.set(ptyId, revision) + return revision + } + + async applyRemoteDesktopLayout(ptyId: string): Promise { + if (this.getDriver(ptyId).kind === 'mobile') { + return true + } + const target = this.activeRemoteDesktopViewport(ptyId) + const reclaimingHost = !target + const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0 + const layoutTarget: PtyLayoutTarget = target + ? { + kind: 'remote-desktop', + cols: target.cols, + rows: target.rows, + ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)! + } + : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, layoutTarget) + // Why: only drop the recorded host size once the reclaim resize actually + // landed. If it failed, the PTY is still at the remote-viewer width, so + // keep the target for the next reclaim (otherwise it resolves via the + // stale remote width and never restores true host geometry). + if ( + reclaimingHost && + result.ok && + !this.remoteDesktopOwners.has(ptyId) && + this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision + ) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + + // Why: attachment only records geometry. Passive hydration/reconnect must not + // steal the shared PTY from the desktop where the user is actively working. + async updateRemoteDesktopViewer( + ptyId: string, + subscriptionKey: string, + clientId: string, + cols: number, + rows: number, + claim = true + ): Promise { + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + viewers = new Map< + string, + { clientId: string; cols: number; rows: number; activity: number } + >() + this.remoteDesktopViewers.set(ptyId, viewers) + } + const prior = viewers.get(subscriptionKey) + if ( + prior && + prior.cols === viewport.cols && + prior.rows === viewport.rows && + (!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey) + ) { + if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + if (size?.cols !== viewport.cols || size.rows !== viewport.rows) { + return this.applyRemoteDesktopLayout(ptyId) + } + } + return true + } + const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0) + viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity }) + this.bumpRemoteDesktopViewerRevision(ptyId) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + return this.applyRemoteDesktopLayout(ptyId) + } + return true + } + + claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey) + if (!viewer) { + return Promise.resolve(false) + } + if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { + const size = this.getTerminalSize(ptyId) + return size?.cols === viewer.cols && size.rows === viewer.rows + ? Promise.resolve(true) + : this.applyRemoteDesktopLayout(ptyId) + } + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + viewer.activity = ++this.remoteDesktopActivity + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { + if (!this.remoteDesktopOwners.has(ptyId)) { + // Why: disconnect can remove the owner before its queued host resize + // lands. A host input in that window must join the reclaim, not pass it. + return this.remoteDesktopHostReclaimTargets.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + const viewport = clampTerminalViewport(cols, rows) + this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) + this.remoteDesktopOwners.delete(ptyId) + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.applyRemoteDesktopLayout(ptyId) + } + + unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { + return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) + } + + unregisterRemoteDesktopViewers( + ptyId: string, + subscriptionKeys: Iterable + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + let changed = false + let removedOwner = false + for (const subscriptionKey of subscriptionKeys) { + removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner + changed = viewers.delete(subscriptionKey) || changed + } + if (!changed) { + return Promise.resolve(false) + } + if (viewers.size === 0) { + this.remoteDesktopViewers.delete(ptyId) + } + if (removedOwner) { + let fallback: { key: string; activity: number } | null = null + for (const [key, viewer] of viewers) { + if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) { + fallback = { key, activity: viewer.activity } + } + } + if (fallback) { + this.remoteDesktopOwners.set(ptyId, fallback.key) + } else { + this.remoteDesktopOwners.delete(ptyId) + } + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) + } + + // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so + // it must never *create* a width floor (that floor would leak — nothing + // releases it, pinning the host at a stale width after the viewer is gone). + // It only refreshes the floor(s) this client already owns via its stream + // subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport` + // no-op-without-subscription invariant. Returns false when the client owns no + // floor (passive/stream-less viewer) — a stream-less viewer must not lock host + // resize. + refreshRemoteDesktopViewer( + ptyId: string, + clientId: string, + cols: number, + rows: number, + claim = false + ): Promise { + const viewers = this.remoteDesktopViewers.get(ptyId) + if (!viewers) { + return Promise.resolve(false) + } + const viewport = clampTerminalViewport(cols, rows) + if (claim) { + // Why: terminal.send may be the first activity while the stream is only + // passively registered. Snapshot host truth before this refresh owns it. + this.ensureRemoteDesktopHostReclaimTarget(ptyId) + } + let changed = false + for (const [subscriptionKey, viewer] of viewers) { + if (viewer.clientId === clientId) { + const activity = claim ? ++this.remoteDesktopActivity : viewer.activity + viewers.set(subscriptionKey, { + ...viewer, + cols: viewport.cols, + rows: viewport.rows, + activity + }) + if (claim) { + this.remoteDesktopOwners.set(ptyId, subscriptionKey) + } + changed = true + } + } + if (!changed) { + return Promise.resolve(false) + } + this.bumpRemoteDesktopViewerRevision(ptyId) + return this.remoteDesktopOwners.has(ptyId) + ? this.applyRemoteDesktopLayout(ptyId) + : Promise.resolve(true) + } + + async updateDesktopViewport( + ptyId: string, + viewport: { cols: number; rows: number } + ): Promise { + const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) + if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') { + this.recordRendererGeometry(ptyId, cols, rows) + return true + } + if (this.isResizeSuppressed()) { + return false + } + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.refreshRendererGeometry(ptyId, cols, rows) + } + return result.ok + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + } + markMobileActor(ptyId: string, clientId: string): void { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) @@ -8199,37 +8581,6 @@ export class OrcaRuntimeService { return { updated: true, applied: result.ok } } - // Why: remote desktop clients do not have the local `pty:resize` IPC path. - // Their measured xterm size still has to resize the source PTY so TUIs - // reflow to the visible client dimensions. - async updateDesktopViewport( - ptyId: string, - viewport: { cols: number; rows: number } - ): Promise { - const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) - if (this.terminalFitOverrides.has(ptyId)) { - // Why: remote desktop panes do not have the local pty:reportGeometry - // IPC. While phone-fit holds the PTY, treat their viewport RPC as a - // measurement-only restore target, not a resize intent. - this.recordRendererGeometry(ptyId, cols, rows) - return true - } - if (this.isResizeSuppressed() || this.getDriver(ptyId).kind === 'mobile') { - return false - } - let resized = false - try { - resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false - } catch { - return false - } - if (!resized) { - return false - } - this.onExternalPtyResize(ptyId, cols, rows) - return true - } - // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take // back" / "Restore" button). Forces the PTY back to desktop dims and flips // the driver to `desktop`, suppressing further mobile-driven dim changes @@ -8261,9 +8612,34 @@ export class OrcaRuntimeService { // the terminal tab on the phone) must default to phone-fit again, not stay // in passive desktop-watch mode. this.setMobileDisplayMode(ptyId, 'auto') + if (this.hasRemoteDesktopLayoutState(ptyId)) { + return this.applyRemoteDesktopLayout(ptyId) + } return true } const heldOverride = this.terminalFitOverrides.get(ptyId) + if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (softLeaver) { + clearTimeout(softLeaver.timer) + this.pendingSoftLeavers.delete(ptyId) + } + const priorDriver = this.getDriver(ptyId) + this.setDriver(ptyId, { kind: 'idle' }) + const converged = await this.applyRemoteDesktopLayout(ptyId) + if (!converged) { + this.setDriver(ptyId, priorDriver) + return false + } + this.setDriver(ptyId, { kind: 'desktop' }) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } if (heldOverride) { const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { @@ -8480,6 +8856,11 @@ export class OrcaRuntimeService { if (prev.kind === 'phone' && next.kind === 'phone') { return prev.ownerClientId === next.ownerClientId } + if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') { + // Why: each owner's claim promise gates its following input. Sharing a + // waiter across owners could release A's input only after B's grid lands. + return prev.ownerSubscriptionKey === next.ownerSubscriptionKey + } return true } @@ -8617,9 +8998,9 @@ export class OrcaRuntimeService { this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) } - // Why: emit fit-override-changed when the *mode* flips. Layouts can - // change dims without flipping mode (keyboard show/hide while phone), - // and waking the renderer on every viewport tick is wasteful churn. + // Why: remote desktop ownership is a fit hold for the host and passive + // peer viewers. Emit every remote layout so owner changes at equal geometry + // still park/release the correct clients without relying on resize deltas. // Defense-in-depth (#7588): also emit when the override's presence // changed even without a kind flip. applyLayout is the sole writer and // keeps override presence in lockstep with layout kind, so overrideChanged @@ -8627,7 +9008,7 @@ export class OrcaRuntimeService { // only if that invariant is ever violated, repairing the renderer instead // of stranding the held modal. const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') - if (modeChanged || overrideChanged) { + if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". @@ -8637,13 +9018,21 @@ export class OrcaRuntimeService { } this.notifier?.terminalFitOverrideChanged( ptyId, - target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', target.cols, target.rows ) this.notifyFitOverrideListeners( ptyId, - target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.kind === 'phone' + ? 'mobile-fit' + : target.kind === 'remote-desktop' + ? 'remote-desktop-fit' + : 'desktop-fit', target.cols, target.rows ) @@ -8963,6 +9352,9 @@ export class OrcaRuntimeService { this.pendingSoftLeavers.delete(ptyId) if (!this.mobileSubscribers.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) + if (this.hasRemoteDesktopViewers(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + } } }, SOFT_LEAVE_GRACE_MS) if (typeof softTimer.unref === 'function') { @@ -9015,6 +9407,10 @@ export class OrcaRuntimeService { if (this.isMobileSubscriberActive(ptyId)) { return } + if (this.hasRemoteDesktopLayoutState(ptyId)) { + void this.applyRemoteDesktopLayout(ptyId) + return + } void this.enqueueLayout(ptyId, { kind: 'desktop', cols: restoreCols, @@ -9150,6 +9546,11 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + // Why: a successful host resize supersedes any target retained after a + // failed viewer reclaim; a later viewer cycle must capture this new truth. + if (!this.hasRemoteDesktopViewers(ptyId)) { + this.remoteDesktopHostReclaimTargets.delete(ptyId) + } this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } @@ -9170,6 +9571,11 @@ export class OrcaRuntimeService { if (cols <= 0 || rows <= 0) { return } + // Why: a viewer may leave while phone-fit still owns the PTY. Keep its + // deferred host reclaim cache aligned with later trusted pane measurements. + if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { + this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) + } this.refreshRendererGeometry(ptyId, cols, rows) } diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts new file mode 100644 index 00000000000..2a75853b1be --- /dev/null +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -0,0 +1,517 @@ +/** + * Tests for the remote-desktop viewer width driver. + * + * A remote (relay/shared-control) desktop viewer takes the PTY width floor so + * the host's own fit cascade stops resizing the viewed PTY out from under it + * (the remote alt-screen "porridge"). Mirrors the mobile presence lock but + * suppresses only RESIZE, never input. Covers: + * - idle → remote-desktop on register; release to idle on last unregister + * - multi-viewer: driver survives until the last viewer detaches + * - a live mobile driver outranks a remote-desktop viewer + * - isPtyResizeDrivenRemotely gates host resize for mobile AND remote-desktop + * - PTY exit clears the remote-desktop registry + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import type * as GitUsernameModule from '../git/git-username' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) +vi.mock('../hooks', () => ({ + createSetupRunnerScript: vi.fn(), + getEffectiveHooks: vi.fn().mockReturnValue(null), + runHook: vi.fn().mockResolvedValue({ success: true, output: '' }) +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() } +}) +vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) +vi.mock('../git/repo', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), + getBranchConflictKind: vi.fn().mockResolvedValue(null) + } +}) +vi.mock('../git/git-username', async () => { + const actual = await vi.importActual('../git/git-username') + return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } +}) + +const store = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [store.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + mobileAutoRestoreFitMs: 5_000 + }) +} + +function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs }) + }) + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + const resizeCalls: { ptyId: string; cols: number; rows: number }[] = [] + const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + resizeCalls.push({ ptyId, cols, rows }) + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + fitOverrideEvents.push({ ptyId, mode, cols, rows }) + }, + terminalDriverChanged: (ptyId, driver) => { + driverEvents.push({ ptyId, driver: { ...driver } }) + } + }) + return { + runtime, + driverEvents, + fitOverrideEvents, + resizeCalls + } +} + +describe('remote desktop viewer width driver', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('applying a viewport suppresses host resize without touching driver state', async () => { + const { runtime, driverEvents } = createRuntime() + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + // It is deliberately NOT a driver kind: the presence-lock state machine and + // its cross-layer driver-change notifications stay untouched. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents).toHaveLength(0) + }) + + it('sizes the PTY to the latest active desktop viewer', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + + // Activity on the narrower viewer transfers ownership to it. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 }) + + // Later activity on the wide viewer transfers ownership back. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 140, 50) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 }) + + // A passive peer leaving cannot disturb the active owner. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 }) + }) + + it('stops suppressing host resize only when the LAST viewer detaches', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // First viewer leaves — another remote viewer still holds the floor. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // Last viewer leaves — the host reclaims its own width (next pty:resize applies). + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('coexists with a mobile driver and outlives it (host stays suppressed)', async () => { + const { runtime, fitOverrideEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // A desktop viewer registering must NOT disturb the mobile driver. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + // When the phone leaves, the surviving viewer keeps host resize suppressed + // (the registry is independent of the mobile driver state). + runtime.onClientDisconnected('phone-A') + vi.advanceTimersByTime(10_000) + expect(runtime.getDriver('pty-1').kind).not.toBe('mobile') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + expect(fitOverrideEvents.at(-1)).toMatchObject({ + ptyId: 'pty-1', + mode: 'remote-desktop-fit', + cols: 100, + rows: 40 + }) + + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('isPtyResizeDrivenRemotely is false for idle and desktop drivers', async () => { + const { runtime } = createRuntime() + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('does not let passive attachment or hydration steal host ownership', async () => { + const { runtime, resizeCalls } = createRuntime() + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24, false) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 30, false) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(resizeCalls).toHaveLength(0) + }) + + it('lets host activity automatically reclaim from a remote owner', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await runtime.claimRemoteDesktopHost('pty-1', 132, 42) + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 132, rows: 42 }) + expect(runtime.getRemoteDesktopFitHold('pty-1', 'sub-A')).toMatchObject({ + mode: 'remote-desktop-fit', + cols: 132, + rows: 42 + }) + }) + + it('does not emit resize churn for repeated activity from the current owner', async () => { + const { runtime, resizeCalls, fitOverrideEvents } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + resizeCalls.splice(0) + fitOverrideEvents.splice(0) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + + expect(resizeCalls).toHaveLength(0) + expect(fitOverrideEvents).toHaveLength(0) + }) + + it('reclaims the host width when the last viewer detaches', async () => { + const { runtime } = createRuntime() + // The viewer drives the source PTY to its own 80-wide viewport. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + // While the viewer owns width, the blocked host fit records the host's + // latest reclaim geometry. + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + + // Detaching the last viewer must actively resize the PTY back to the host's + // OWN width (120), not the departed viewer's polluted 80. + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 }) + }) + + it('does not retain a remote reclaim target when only a phone suppresses host resize', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + + // pty:resize is suppressed for mobile too, but this measurement must not + // seed the separate remote-viewer cache when no desktop viewer exists. + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 35) + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('restores and consumes the host target when the last viewer leaves during phone-fit', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + // The host pane can change after remote ownership ends while phone-fit + // remains active; its trusted measurement becomes the deferred target. + runtime.recordRendererGeometry('pty-1', 140, 38) + + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 38 }) + + // A later viewer must capture the new host geometry, not reuse the prior + // session's already-consumed 140-column reclaim target. + runtime.onExternalPtyResize('pty-1', 130, 36) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 130, rows: 36 }) + }) + + it('preserves indefinite phone-fit after the last desktop viewer leaves', async () => { + const { runtime } = createRuntime(null) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 45, rows: 20 }) + expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' }) + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) + + it('does not let an older host reclaim consume a newer viewer cycle target', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + // Do not await the reclaim before the next viewer joins. The serialized + // host resize can finish after sub-B has established a newer viewer cycle. + const firstReclaim = runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + const secondAttach = runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 90, 30) + await Promise.all([firstReclaim, secondAttach]) + + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('does not coalesce queued claims from different desktop owners', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24, false) + const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map< + string, + { running: Promise; pending: { target: { ownerSubscriptionKey?: string } }[] } + > + layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] }) + + void runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 28) + void runtime.claimRemoteDesktopViewer('pty-1', 'sub-B') + + expect( + layoutQueues.get('pty-1')?.pending.map(({ target }) => target.ownerSubscriptionKey) + ).toEqual(['sub-A', 'sub-B']) + layoutQueues.delete('pty-1') + }) + + it('makes a host claim join a pending disconnect reclaim', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map< + string, + { running: Promise; pending: { waiters: unknown[] }[] } + > + layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] }) + + void runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + void runtime.claimRemoteDesktopHost('pty-1', 150, 40) + + expect(layoutQueues.get('pty-1')?.pending).toHaveLength(1) + expect(layoutQueues.get('pty-1')?.pending[0]?.waiters).toHaveLength(2) + layoutQueues.delete('pty-1') + }) + + it('removes same-PTY viewer floors with one bounded reclaim', async () => { + const { runtime, resizeCalls } = createRuntime() + const subscriptionKeys: string[] = [] + for (let index = 0; index < 100; index += 1) { + const key = `sub-${index}` + subscriptionKeys.push(key) + await runtime.updateRemoteDesktopViewer('pty-1', key, `viewer-${index}`, 100, 30) + } + resizeCalls.splice(0) + + await runtime.unregisterRemoteDesktopViewers('pty-1', subscriptionKeys) + + expect(resizeCalls).toEqual([{ ptyId: 'pty-1', cols: 150, rows: 40 }]) + }) + + it('applies the latest viewer width when the host takes back from a phone', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + }) + + it('refresh never creates a floor (one-shot viewport cannot leak host suppression)', async () => { + const { runtime } = createRuntime() + // A one-shot terminal.updateViewport (refresh) from a viewer with no stream + // floor must NOT register one — that floor would leak (nothing releases a + // one-shot RPC's state), pinning the host at a stale width forever. + const created = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 90, 30) + expect(created).toBe(false) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + + // Once the client's STREAM registers the floor (and owns its cleanup), a + // refresh updates that same floor by clientId. + await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 }) + const refreshed = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 70, 25) + expect(refreshed).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 }) + + // A refresh for a DIFFERENT client that owns no floor is a no-op. + const other = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-Z', 60, 20) + expect(other).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 }) + + // The stream cleanup releases the sole floor — the refresh left no orphan, + // so the host reclaims (suppression drops). + await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1') + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + }) + + it('captures host geometry when a passive stream is first claimed by terminal.send', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 30, false) + + await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 80, 24, true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1') + + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + it('keeps the host reclaim target when the reclaim resize fails', async () => { + const { runtime } = createRuntime() + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + let resizeSucceeds = true + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + if (!resizeSucceeds) { + return false + } + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + + // A viewer drives the PTY to 80; while it owns width, the host reports its + // own 120-wide reclaim geometry. + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + + // The last viewer leaves but the reclaim resize fails: the PTY is stuck at + // 80, so the recorded host target (120) MUST be retained for a later retry. + resizeSucceeds = false + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 }) + + // A retry once resize works restores the TRUE host width (120), not the + // stale viewer width (80) that a dropped target would have resolved to. + resizeSucceeds = true + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 }) + }) + + it('supersedes a failed reclaim target when the host later resizes successfully', async () => { + const { runtime } = createRuntime() + const ptySizes = new Map([ + ['pty-1', { cols: 150, rows: 40 }] + ]) + let resizeSucceeds = true + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + if (!resizeSucceeds) { + return false + } + ptySizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => ptySizes.get(ptyId) ?? null + }) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40) + runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40) + resizeSucceeds = false + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + + // pty:resize has already resized the provider before this runtime mirror + // hook runs; that successful host geometry supersedes the retained 120. + resizeSucceeds = true + ptySizes.set('pty-1', { cols: 140, rows: 42 }) + runtime.onExternalPtyResize('pty-1', 140, 42) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B') + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 42 }) + }) + + it('PTY exit clears the remote-desktop registry', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + + runtime.onPtyExit('pty-1', 0) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + + // A fresh viewer on the same id re-establishes suppression cleanly (no stale set). + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 100, 40) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 339ae8751a9..9c39a65f456 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -96,6 +96,14 @@ type TerminalMultiplexStream = { isMobile: boolean ackOutput: boolean ackInFlightBytes: number + supportsDesktopViewportClaims: boolean + desktopClaimTail: Promise + // Why: whether THIS stream registered a remote-desktop width driver, so + // detach only unregisters what it registered — a passive (viewport-less) + // stream sharing a client id must not release another stream's width floor. + registeredRemoteDesktopDriver: boolean + remoteDesktopSubscriptionKey: string + pendingRemoteDesktopViewport: { cols: number; rows: number } | null buffering: boolean ackPendingOutput: TerminalOutputFrameChunk[] ackPendingOutputBytes: number @@ -662,15 +670,39 @@ async function sendMobileResizeRestream( async function updateViewportForClient( runtime: OrcaRuntimeService, ptyId: string, + subscriptionKey: string, client: TerminalViewportClient, viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop' + defaultType: 'mobile' | 'desktop', + // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so + // it must only refresh a floor the client already owns via its stream (never + // create a leak-prone standalone one). Stream paths that own cleanup register. + registration: 'register' | 'refresh' = 'register', + claim = false ): Promise<{ updated: boolean; applied: boolean }> { const type = client.type ?? defaultType if (type === 'mobile') { return runtime.updateMobileViewport(ptyId, client.id, viewport) } - const updated = await runtime.updateDesktopViewport(ptyId, viewport) + // Why: stream attachment observes geometry without taking control. Only a + // later activity/claim frame may make this desktop authoritative. + const updated = + registration === 'refresh' + ? await runtime.refreshRemoteDesktopViewer( + ptyId, + client.id, + viewport.cols, + viewport.rows, + claim + ) + : await runtime.updateRemoteDesktopViewer( + ptyId, + subscriptionKey, + client.id, + viewport.cols, + viewport.rows, + claim + ) return { updated, applied: updated } } @@ -743,7 +775,14 @@ const TerminalSend = TerminalHandle.extend({ id: requiredString('Missing client ID'), type: z.enum(['mobile', 'desktop']).default('desktop').optional() }) - .optional() + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() }) const TerminalViewport = z.object({ @@ -840,7 +879,8 @@ const TerminalSubscribe = TerminalHandle.extend({ viewport: TerminalViewport.optional(), capabilities: z .object({ - terminalBinaryStream: z.literal(1).optional() + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional() }) .optional() }) @@ -858,7 +898,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ viewport: TerminalViewport.optional(), capabilities: z .object({ - ackOutput: z.literal(1).optional() + ackOutput: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional() }) .optional() }) @@ -929,7 +970,8 @@ const TerminalUpdateViewport = TerminalHandle.extend({ viewport: z.object({ cols: z.number().int().min(20).max(240), rows: z.number().int().min(8).max(120) - }) + }), + claim: z.boolean().optional() }) // Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md). `null` @@ -1060,6 +1102,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + if ( + leaf?.ptyId && + params.client?.type === 'desktop' && + params.claimViewport === true && + params.viewport + ) { + const claim = await updateViewportForClient( + runtime, + leaf.ptyId, + `send:${params.client.id}`, + params.client, + params.viewport, + 'desktop', + 'refresh', + true + ) + // Why: a stream-less request has no lifecycle cleanup and cannot safely + // create ownership. Never write at stale geometry if no stream exists. + if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + } const hasText = typeof params.text === 'string' && params.text.length > 0 const hasSuffix = params.enter === true || params.interrupt === true if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { @@ -1341,9 +1411,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const viewportUpdate = await updateViewportForClient( runtime, leaf.ptyId, + `viewport:${params.client.id}`, params.client, params.viewport, - 'mobile' + 'mobile', + // Why: one-shot RPC with no disconnect hook — refresh the client's + // existing stream-owned floor only, never create a leak-prone one. + 'refresh', + params.claim === true ) return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } } @@ -1542,7 +1617,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) flushAllAckPendingOutput() } - const detachStream = (streamId: number, emitEnd: boolean): void => { + const detachStream = ( + streamId: number, + emitEnd: boolean, + releaseRemoteDesktopDriver = true + ): void => { const stream = streams.get(streamId) if (!stream) { return @@ -1567,6 +1646,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.exitWaiterAbort.abort() if (stream.isMobile && stream.client?.id) { runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } else if ( + releaseRemoteDesktopDriver && + stream.registeredRemoteDesktopDriver && + stream.client?.id + ) { + // Why: release the remote-desktop width floor so the host can reclaim + // its own width once the last remote viewer leaves — but only if THIS + // stream took it (a passive stream must not release a peer's floor). + runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) } if (emitEnd) { emit({ type: 'end', streamId }) @@ -1577,8 +1665,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } closed = true + const remoteDesktopKeysByPty = new Map() for (const streamId of Array.from(streams.keys())) { - detachStream(streamId, false) + const stream = streams.get(streamId) + if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { + const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] + keys.push(stream.remoteDesktopSubscriptionKey) + remoteDesktopKeysByPty.set(stream.ptyId, keys) + } + detachStream(streamId, false, false) + } + // Why: one connection can own many panes backed by the same PTY. + // Remove those floors together so close scans each PTY registry once. + for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { + void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) } unregisterControlHandler() resolveMultiplex() @@ -1611,8 +1711,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { return } - void runtime - .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + // Mobile already has the higher-priority floor; a rejected desktop + // viewport claim must never suppress later phone input. + const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + void inputClaimTail + .then((claimed) => + !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) + ? null + : runtime.sendTerminal(stream.terminal, { + text, + enter: false, + interrupt: false + }) + ) .then(async () => { if (stream.isMobile && stream.client?.id) { await runtime.mobileTookFloor(stream.ptyId, stream.client.id) @@ -1628,13 +1739,72 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { return } - void updateViewportForClient( - runtime, - stream.ptyId, - stream.client, - { cols: viewport.cols, rows: viewport.rows }, - stream.isMobile ? 'mobile' : 'desktop' - ).catch(() => {}) + const cols = viewport.cols + const rows = viewport.rows + // Why: resize registers stream-scoped geometry so detach can release + // it. Older clients lack explicit claims, so Resize remains control. + if (!stream.isMobile && stream.client?.id) { + stream.registeredRemoteDesktopDriver = true + if (stream.buffering) { + stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + stream.desktopClaimTail = stream.desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!, + { cols, rows }, + stream.isMobile ? 'mobile' : 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + return stream.supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + stream.client && + !stream.isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + stream.registeredRemoteDesktopDriver = true + stream.desktopClaimTail = stream.desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + ) + .catch(() => false) return } if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { @@ -1730,6 +1900,28 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false stream.outputBatcher.flush() + // Why: a viewer resize that arrived during the snapshot buffering + // window is parked in pendingRemoteDesktopViewport; apply it now or + // it is silently dropped until the viewer's next resize. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } } } } @@ -1791,6 +1983,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ isMobile, ackOutput: request.capabilities?.ackOutput === 1, ackInFlightBytes: 0, + supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, + desktopClaimTail: Promise.resolve(true), + registeredRemoteDesktopDriver: false, + // Why: streamId is client-local, so two remote connections can both + // use stream 1 for the same PTY. Scope the width-floor key by + // connectionId (guaranteed present above) so they can't + // overwrite/release each other's floor. + remoteDesktopSubscriptionKey: `multiplex:${connectionId}:${request.streamId}`, + pendingRemoteDesktopViewport: null, buffering: true, ackPendingOutput: [], ackPendingOutputBytes: 0, @@ -1849,13 +2050,29 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.viewport && request.client) { + } else if (request.client?.id && request.viewport) { + // Why: subscribe records this stream's geometry and cleanup key, + // but does not claim ownership. Activity frames claim later. + stream.registeredRemoteDesktopDriver = true + stream.pendingRemoteDesktopViewport = request.viewport + } + if ( + !isMobile && + request.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null await updateViewportForClient( runtime, ptyId, + stream.remoteDesktopSubscriptionKey, request.client, - request.viewport, - 'desktop' + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims ) } if (closed || streams.get(request.streamId) !== stream) { @@ -1864,10 +2081,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!isMobile) { stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) + .mode ?? 'desktop-fit') emit({ type: 'fit-override-changed', streamId: request.streamId, - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) @@ -1910,12 +2132,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const snapshotOutputSeq = serialized?.seq if (!isMobile) { const fitOverride = runtime.getTerminalFitOverride(ptyId) + const desktopHold = runtime.getRemoteDesktopFitHold?.( + ptyId, + stream.remoteDesktopSubscriptionKey + ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } emit({ type: 'fit-override-changed', streamId: request.streamId, - mode: fitOverride?.mode ?? 'desktop-fit', - cols: fitOverride?.cols ?? size?.cols ?? 0, - rows: fitOverride?.rows ?? size?.rows ?? 0 + mode: fitOverride?.mode ?? desktopHold.mode, + cols: fitOverride?.cols ?? desktopHold.cols, + rows: fitOverride?.rows ?? desktopHold.rows }) emit({ type: 'driver-changed', @@ -1967,7 +2193,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false stream.outputBatcher.flush() - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { stream.outputBatcher.flush() const resizeGeneration = stream.resizeGeneration + 1 @@ -2016,6 +2241,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } sendResizedFrame(stream, event) }) + // Install the resize listener before draining the parked viewport; + // applyLayout emits synchronously and the stream must observe it. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } void runtime .waitForTerminal(request.terminal, { condition: 'exit', @@ -2108,40 +2354,86 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + // Why: only unregister the width floor this subscription took (see the + // multiplex stream's registeredRemoteDesktopDriver note). + let registeredRemoteDesktopDriver = false if (!useBinaryStream) { - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - // Why: legacy JSON streams register cleanup after snapshot awaits; if - // the socket closed meanwhile, registering now would orphan listeners. - if (signal?.aborted) { - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq - }) - // Why: desktop can have both a hidden automation watcher and a visible // pane subscribed to the same terminal. Key by client when provided so // one stream cannot evict the other. const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - await new Promise((resolve) => { - const outputBatcher = createTerminalOutputBatcher((chunk) => { + const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` + let closed = false + let outputBatcher: ReturnType | null = null + let unsubscribeData = (): void => {} + let unsubscribeFit = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register before viewport/snapshot awaits so a socket close cannot + // orphan either the stream listeners or its remote-desktop width floor. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + try { + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (closed || signal?.aborted) { + runtime.cleanupSubscription(subscriptionId) + return + } + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (closed || signal?.aborted) { + runtime.cleanupSubscription(subscriptionId) + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher.push(data) + outputBatcher?.push(data) }) // Why: this legacy JSON stream can feed a live xterm view too // (older web/desktop subscribers), so it conservatively registers @@ -2149,50 +2441,50 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // a withheld model reply — the pre-Phase-5 status quo — which is // strictly safer than a double reply under a view consumer. const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - const unsubscribeData = (): void => { + unsubscribeData = () => { releaseViewSubscriber() unsubscribeStreamData() } - const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher.flush() + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') emit({ type: 'fit-override-changed', - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) }) - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - outputBatcher.flush() - outputBatcher.dispose() - unsubscribeData() - unsubscribeFit() - emit({ type: 'end' }) - resolve() - }, - connectionId - ) // Why: bind the exit-waiter to the connection dispatch signal so it is // removed on socket close/error instead of leaking until real exit. void runtime .waitForTerminal(params.terminal, { condition: 'exit', signal }) .then(() => runtime.cleanupSubscription(subscriptionId)) .catch(() => runtime.cleanupSubscription(subscriptionId)) - }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } return } const streamId = nextTerminalStreamId++ + const remoteDesktopSubscriptionKey = `stream:${streamId}` let cursor = 0 let closed = false let buffering = true + let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null // Why: the cols the mobile client last rewrapped to; gate the // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 let pendingOutput: TerminalOutputChunk[] = [] + let desktopClaimTail: Promise = Promise.resolve(true) let pendingOutputBytes = 0 let pendingOutputOverflowed = false let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE @@ -2224,6 +2516,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unregisterBinaryHandler() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) + } else if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) } emit({ type: 'end' }) resolveStream() @@ -2271,8 +2565,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void runtime - .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) + void desktopClaimTail + .then((claimed) => + !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) + ? null + : runtime.sendTerminal(params.terminal, { + text, + enter: false, + interrupt: false + }) + ) .then(async () => { if (isMobile && clientId) { await runtime.mobileTookFloor(ptyId, clientId) @@ -2292,13 +2594,75 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) { return } - void updateViewportForClient( - runtime, - ptyId, - params.client, - { cols: viewport.cols, rows: viewport.rows }, - 'desktop' - ).catch(() => {}) + const cols = viewport.cols + const rows = viewport.rows + if (clientId) { + registeredRemoteDesktopDriver = true + if (buffering) { + pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + desktopClaimTail = desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client!, + { cols, rows }, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + return supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + params.client && + clientId && + !isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( + frame.payload + ) + if ( + !viewport || + typeof viewport.cols !== 'number' || + typeof viewport.rows !== 'number' + ) { + return + } + const cols = viewport.cols + const rows = viewport.rows + registeredRemoteDesktopDriver = true + desktopClaimTail = desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + ) + .catch(() => false) } }) ?? (() => {}) const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { @@ -2356,6 +2720,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ try { if (isMobile && clientId) { await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } else if (clientId && params.viewport) { + // Why: legacy subscribe records geometry without taking ownership; + // only an explicit activity/claim frame may suppress the host. + registeredRemoteDesktopDriver = true + pendingRemoteDesktopViewport = params.viewport } if (closed) { return @@ -2526,7 +2895,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() - const sendResizedFrame = (event: { cols: number rows: number @@ -2586,12 +2954,39 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendResizedFrame(event) }) + // Install the resize listener before draining the parked viewport; + // applyLayout emits synchronously and the stream must observe it. + if ( + clientId && + params.client && + registeredRemoteDesktopDriver && + pendingRemoteDesktopViewport + ) { + const viewport = pendingRemoteDesktopViewport + pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ).catch(() => {}) + } + // Legacy fit-override-changed for non-mobile (desktop) subscribers unsubscribeFit = !isMobile ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') emit({ type: 'fit-override-changed', - mode: event.mode, + mode, cols: event.cols, rows: event.rows }) diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index 9cdc88a021b..77deacc420c 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -315,6 +315,9 @@ describe('RpcDispatcher streaming', () => { ) await vi.waitFor(() => expect(cleanups.has('terminal-1:desktop-1')).toBe(true)) + // Cleanup now registers before snapshot work so a disconnect cannot orphan + // a desktop width floor; wait for the actual exit waiter before resolving it. + await vi.waitFor(() => expect(runtime.waitForTerminal).toHaveBeenCalled()) resolveExit() await dispatchPromise diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 62dcfddc5ab..20b608c94c2 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -26,6 +26,12 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // only stub the legacy resolveLeafForHandle still bind; tests that need a // null/stale leaf override this explicitly. resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true), + isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false), + getRemoteDesktopFitHold: vi.fn().mockReturnValue({ mode: 'desktop-fit', cols: 120, rows: 40 }), + isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false), ...overrides } as OrcaRuntimeService } @@ -82,8 +88,7 @@ describe('terminal multiplex RPC', () => { cleanup?.() }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), - updateDesktopViewport: vi.fn().mockResolvedValue(true) + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) }) const dispatcher = new RpcDispatcher({ runtime, @@ -120,7 +125,8 @@ describe('terminal multiplex RPC', () => { streamId: 5, terminal: 'terminal-1', client: { id: 'desktop-1', type: 'desktop' }, - viewport: { cols: 300, rows: 150 } + viewport: { cols: 300, rows: 150 }, + capabilities: { desktopViewportClaims: 1 } }) }) )! @@ -143,12 +149,134 @@ describe('terminal multiplex RPC', () => { }) ]) ) - expect(runtime.updateDesktopViewport).toHaveBeenCalledWith('pty-1', { - cols: 300, - rows: 150 - }) + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 300, + 150, + false + ) expect(handlers.has(5)).toBe(true) + let releaseClaim = (): void => {} + vi.mocked(runtime.updateRemoteDesktopViewer).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + ) + + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ClaimViewport, + streamId: 5, + seq: 0, + payload: encodeTerminalStreamJson({ cols: 96, rows: 32 }) + }) + )! + ) + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + true + ) + ) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 1, + payload: encodeTerminalStreamJson({ cols: 96, rows: 32 }) + }) + )! + ) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamText('ls\r') + }) + )! + ) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.updateRemoteDesktopViewer).not.toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + false + ) + releaseClaim() + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 96, + 32, + false + ) + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'ls\r', + enter: false, + interrupt: false + }) + ) + const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length + vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) + for (const [opcode, seq, payload] of [ + [TerminalStreamOpcode.ClaimViewport, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Resize, 4, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Input, 5, encodeTerminalStreamText('blocked')] + ] as const) { + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload }) + )! + ) + } + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 88, + 28, + false + ) + ) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(sentAfterSuccessfulClaim) + for (const [opcode, seq, payload] of [ + [TerminalStreamOpcode.ClaimViewport, 6, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Resize, 7, encodeTerminalStreamJson({ cols: 88, rows: 28 })], + [TerminalStreamOpcode.Input, 8, encodeTerminalStreamText('retry')] + ] as const) { + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload }) + )! + ) + } + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { + text: 'retry', + enter: false, + interrupt: false + }) + ) + dataListenerRef.current?.('a') dataListenerRef.current?.('b') await vi.runOnlyPendingTimersAsync() @@ -160,24 +288,6 @@ describe('terminal multiplex RPC', () => { expect(outputFrames[0]?.streamId).toBe(5) expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab') - handlers.get(5)?.( - decodeTerminalStreamFrame( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - streamId: 5, - seq: 2, - payload: encodeTerminalStreamText('ls\r') - }) - )! - ) - await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) - ) - handlers.get(5)?.( decodeTerminalStreamFrame( encodeTerminalStreamFrame({ @@ -189,10 +299,14 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.updateDesktopViewport).toHaveBeenLastCalledWith('pty-1', { - cols: 100, - rows: 30 - }) + expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith( + 'pty-1', + 'multiplex:conn-1:5', + 'desktop-1', + 100, + 30, + false + ) ) const snapshotStartFrame = binaryFrames @@ -248,13 +362,318 @@ describe('terminal multiplex RPC', () => { .join('') ).toBe('snapshot') + // A viewport-less stream is passive: it must neither register nor later + // release the active stream's width floor when the connection closes. + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 5, + payload: encodeTerminalStreamJson({ + streamId: 6, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + messages.some( + (msg) => + JSON.parse(msg).result?.type === 'subscribed' && + JSON.parse(msg).result?.streamId === 6 + ) + ).toBe(true) + ) + + // A second active floor on the same PTY is released in the same batch, + // keeping connection teardown to one registry recomputation per PTY. + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 6, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'desktop-2', type: 'desktop' }, + viewport: { cols: 90, rows: 30 } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + messages.some( + (msg) => + JSON.parse(msg).result?.type === 'subscribed' && + JSON.parse(msg).result?.streamId === 7 + ) + ).toBe(true) + ) + runtime.cleanupSubscription('terminal-multiplex:conn-1') await dispatchPromise + expect(runtime.unregisterRemoteDesktopViewer).not.toHaveBeenCalled() + expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledTimes(1) + expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledWith('pty-1', [ + 'multiplex:conn-1:5', + 'multiplex:conn-1:7' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('applies a viewer resize parked during a snapshot-request buffering window', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-snap', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 300, rows: 150 }, + capabilities: { desktopViewportClaims: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + // Ignore the subscribe-time floor registration; assert only the drained one. + vi.mocked(runtime.updateRemoteDesktopViewer).mockClear() + + // A snapshot request opens the buffering window synchronously (buffering + // is set before the first await inside the handler)... + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 9, + seq: 2, + payload: encodeTerminalStreamJson({ requestId: 3, scrollbackRows: 1000 }) + }) + )! + ) + // ...so a resize arriving now is PARKED, not applied inline. + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 9, + seq: 3, + payload: encodeTerminalStreamJson({ cols: 88, rows: 33 }) + }) + )! + ) + expect(runtime.updateRemoteDesktopViewer).not.toHaveBeenCalled() + + // Once the snapshot completes and buffering clears, the parked resize is + // drained (previously it was silently dropped until the next resize). + await vi.waitFor(() => + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + 'multiplex:conn-snap:9', + 'desktop-1', + 88, + 33, + false + ) + ) + + runtime.cleanupSubscription('terminal-multiplex:conn-snap') + await dispatchPromise } finally { vi.useRealTimers() } }) + it('emits a resize drained after the initial snapshot', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let resolveSnapshot = (_value: { data: string; cols: number; rows: number }): void => {} + let resizeListener: + | ((event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void) + | undefined + const updateRemoteDesktopViewer = vi.fn( + async (_ptyId: string, _key: string, _clientId: string, cols: number, rows: number) => { + if (updateRemoteDesktopViewer.mock.calls.length > 1) { + resizeListener?.({ cols, rows, displayMode: 'desktop', reason: 'apply-layout', seq: 2 }) + } + return true + } + ) + const runtime = stubRuntime({ + updateRemoteDesktopViewer, + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn((_ptyId, listener) => { + resizeListener = listener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-initial-resize', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 80, rows: 24 } + }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + handlers.get(9)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 9, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 132, rows: 43 }) + }) + )! + ) + expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(1) + + resolveSnapshot({ data: 'snapshot', cols: 80, rows: 24 }) + await vi.waitFor(() => + expect( + binaryFrames.some( + (bytes) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Resized + ) + ).toBe(true) + ) + const opcodes = binaryFrames.map((bytes) => decodeTerminalStreamFrame(bytes)?.opcode) + expect(opcodes.indexOf(TerminalStreamOpcode.Resized)).toBeGreaterThan( + opcodes.indexOf(TerminalStreamOpcode.SnapshotEnd) + ) + + runtime.cleanupSubscription('terminal-multiplex:conn-initial-resize') + await dispatchPromise + }) + it('drops stale mobile resize re-stream completions for multiplex streams', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -305,7 +724,7 @@ describe('terminal multiplex RPC', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})), sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), - updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) + updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ runtime, @@ -346,6 +765,34 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => expect(resizeListener).toBeDefined()) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + streamId: 5, + seq: 2, + payload: encodeTerminalStreamJson({ cols: 90, rows: 24 }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.updateMobileViewport).toHaveBeenCalled()) + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 5, + seq: 3, + payload: encodeTerminalStreamText('x') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'x', + enter: false, + interrupt: false + }) + ) binaryFrames.splice(0) resizeListener?.({ @@ -1712,6 +2159,58 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('owns and releases a viewport floor for legacy JSON desktop streams', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-json-1', type: 'desktop' }, + viewport: { cols: 88, rows: 30 } + }), + (msg) => messages.push(msg), + { connectionId: 'conn-json-1' } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'scrollback')).toBe(true) + ) + const subscriptionKey = vi.mocked(runtime.updateRemoteDesktopViewer).mock.calls[0]?.[1] + expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + expect.stringMatching(/^json:/), + 'desktop-json-1', + 88, + 30, + true + ) + + runtime.cleanupSubscription('terminal-1:desktop-json-1') + await dispatchPromise + expect(runtime.unregisterRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', subscriptionKey) + }) + it('bounds oversized live output frames for subscribed binary streams', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 243003eeec3..12e40c2c977 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -136,6 +136,46 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) + it('awaits a desktop viewport claim before acknowledged input', async () => { + let releaseClaim = (): void => {} + const refreshRemoteDesktopViewer = vi.fn( + () => + new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + ) + const sendTerminal = vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + refreshRemoteDesktopViewer, + sendTerminal + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x03', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 96, rows: 32 }, + claimViewport: true + }) + ) + + await vi.waitFor(() => + expect(refreshRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', 'desktop-1', 96, 32, true) + ) + expect(sendTerminal).not.toHaveBeenCalled() + releaseClaim() + await expect(response).resolves.toMatchObject({ ok: true }) + expect(sendTerminal).toHaveBeenCalled() + }) + it('accepts legacy clientless mobile input when the current driver is mobile', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 06b006a4d0e..4a674f9df91 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -21,9 +21,12 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe } as OrcaRuntimeService } -function makeRequest(method: string, params?: unknown): RpcRequest { - return { id: 'req-1', authToken: 'tok', method, params } -} +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) describe('terminal subscribe buffering', () => { it('settles mobile subscribe waits when the stream signal aborts before PTY spawn', async () => { @@ -43,10 +46,7 @@ describe('terminal subscribe buffering', () => { ), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }) }) - const dispatcher = new RpcDispatcher({ - runtime, - methods: TERMINAL_METHODS - }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -178,10 +178,7 @@ describe('terminal subscribe buffering', () => { limited: true }) }) - const dispatcher = new RpcDispatcher({ - runtime, - methods: TERMINAL_METHODS - }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) await dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1' }), @@ -303,7 +300,14 @@ describe('terminal subscribe buffering', () => { expect(await outcomePromise).toBe('settled') expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() - expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled() + // Cleanup must exist before snapshot work so an abort cannot orphan a + // desktop width floor, but the abort must consume it before listeners start. + expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith( + 'terminal-1:desktop-1', + expect.any(Function), + 'conn-legacy-json' + ) + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('terminal-1:desktop-1') expect(runtime.waitForTerminal).not.toHaveBeenCalled() expect(messages).toEqual([]) } finally { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ad3c409d0ec..1bad72bb019 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1254,6 +1254,7 @@ export type PreloadApi = { write: (id: string, data: string) => void writeAccepted: (id: string, data: string) => Promise resize: (id: string, cols: number, rows: number) => void + claimViewport: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void clearBuffer: (id: string) => void @@ -2907,7 +2908,7 @@ export type PreloadApi = { getStatus: () => Promise call: (args: { method: string; params?: unknown }) => Promise> getTerminalFitOverrides: () => Promise< - { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > getTerminalDrivers: () => Promise< { @@ -2926,7 +2927,7 @@ export type PreloadApi = { onTerminalFitOverrideChanged: ( callback: (event: { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index ae6b06ca175..6f8692649b4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -847,6 +847,9 @@ const api = { resize: (id: string, cols: number, rows: number): void => { ipcRenderer.send('pty:resize', { id, cols, rows }) }, + claimViewport: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + }, /** Why: measurement-only sibling of resize. Fires when a desktop pane * container measures real geometry (e.g. previously hidden tab becomes @@ -3879,7 +3882,7 @@ const api = { call: (args: { method: string; params?: unknown }): Promise> => ipcRenderer.invoke('runtime:call', args), getTerminalFitOverrides: (): Promise< - { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), getTerminalDrivers: (): Promise< { @@ -3900,14 +3903,19 @@ const api = { onTerminalFitOverrideChanged: ( callback: (event: { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number } + data: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } ) => callback(data) ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8c7f3cd1b03..a3d0286efac 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -88,10 +88,11 @@ import { connectPanePty } from './pty-connection' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers' import { - getAllOverrides, + getMobileFitOverridePtyIds, getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides' +import { shouldShowMobileDriverOverlay } from './mobile-driver-overlay-visibility' import { getAllDrivers, getDriverForPty, @@ -441,7 +442,7 @@ export default function TerminalPane({ (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(), event.ptyId ) - if (event.mode === 'mobile-fit') { + if (event.mode === 'mobile-fit' || event.mode === 'remote-desktop-fit') { // Why: when mobile starts driving, the agent re-renders its output at // phone width and that phone-wrapped byte stream flows live into this // passive watcher's xterm. xterm must shrink to the phone dims now or @@ -2659,7 +2660,7 @@ export default function TerminalPane({ }, [getContextMenuLeafId, toggleNativeChatForLeaf]) const getMobileOwnedTerminalPtyIds = useCallback((): string[] => { - const ptyIds = new Set(getAllOverrides().keys()) + const ptyIds = new Set(getMobileFitOverridePtyIds()) for (const [ptyId, driver] of getAllDrivers()) { if (driver.kind === 'mobile') { ptyIds.add(ptyId) @@ -3183,9 +3184,9 @@ export default function TerminalPane({ // treatment and collapse-to-chip state; both branches share the // same local/remote desktop-restore route. const driver = getDriverForPty(ptyId) - const isMobileDriving = driver.kind === 'mobile' - const hasFitOverride = getFitOverrideForPty(ptyId) !== null - if (!isMobileDriving && !hasFitOverride) { + const fitMode = getFitOverrideForPty(ptyId)?.mode ?? null + const hasFitOverride = fitMode === 'mobile-fit' + if (!shouldShowMobileDriverOverlay(driver.kind, fitMode)) { return null } // Why: only the pane replaced by native chat should hide terminal-owned diff --git a/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts b/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts new file mode 100644 index 00000000000..36f8f0a4245 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowMobileDriverOverlay } from './mobile-driver-overlay-visibility' + +describe('shouldShowMobileDriverOverlay', () => { + it('shows only mobile ownership and phone-fit holds', () => { + expect(shouldShowMobileDriverOverlay('mobile', null)).toBe(true) + expect(shouldShowMobileDriverOverlay('idle', 'mobile-fit')).toBe(true) + expect(shouldShowMobileDriverOverlay('idle', 'remote-desktop-fit')).toBe(false) + expect(shouldShowMobileDriverOverlay('desktop', 'desktop-fit')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.ts b/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.ts new file mode 100644 index 00000000000..82534030454 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.ts @@ -0,0 +1,8 @@ +import type { FitHoldMode } from '@/lib/pane-manager/mobile-fit-overrides' + +export function shouldShowMobileDriverOverlay( + driverKind: 'idle' | 'desktop' | 'mobile', + fitMode: FitHoldMode | null +): boolean { + return driverKind === 'mobile' || fitMode === 'mobile-fit' +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index f04eb1757f5..c2815c88496 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -242,6 +242,7 @@ type MockTransport = { sendInput: ReturnType sendInputImmediate: ReturnType sendInputAccepted?: ReturnType + claimViewport: ReturnType resize: ReturnType getPtyId: ReturnType getConnectionId: ReturnType @@ -383,6 +384,7 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport return ptyId }), sendInput: vi.fn(() => true), + claimViewport: vi.fn(() => true), resize: vi.fn(() => true), getPtyId: vi.fn(() => ptyId), getConnectionId: vi.fn(() => null), @@ -4000,6 +4002,7 @@ describe('connectPanePty', () => { const manager = createManager(1) const replayingPanesRef = { current: new Map([[1, 1]]) } const deps = createDeps({ replayingPanesRef }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') connectPanePty(pane as never, manager as never, deps as never) @@ -4010,11 +4013,19 @@ describe('connectPanePty', () => { // Simulate xterm emitting a DA1 auto-reply during replay parse. ;(onDataHandler as (data: string) => void)('\x1b[?1;2c') expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.claimViewport).not.toHaveBeenCalled() // Once replay completes (guard cleared), real keystrokes flow through. replayingPanesRef.current.delete(1) + setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) ;(onDataHandler as (data: string) => void)('a') expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.claimViewport).toHaveBeenCalledTimes(1) + ;(onDataHandler as (data: string) => void)('b') + // The renderer stays parked until runtime convergence is acknowledged, so + // a second keystroke can retry a transient failed resize. + expect(transport.claimViewport).toHaveBeenCalledTimes(2) + setFitOverride('pty-live', 'desktop-fit', 0, 0) }) it('does not enumerate every worktree tab for ordinary input without Codex restart notices', async () => { @@ -11694,7 +11705,7 @@ describe('connectPanePty', () => { isVisibleRef.current = true resizeHandler({ cols: 122, rows: 42 }) - expect(transport.resize).toHaveBeenCalledWith(122, 42) + expect(transport.resize).toHaveBeenCalledWith(122, 42, { claim: true }) disposable.dispose() }) @@ -18244,7 +18255,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') - expect(transport.resize).toHaveBeenCalledWith(82, 40) + expect(transport.resize).toHaveBeenCalledWith(82, 40, { claim: true }) } finally { observer.restore() } @@ -18293,7 +18304,7 @@ describe('connectPanePty', () => { expect(pane.fitAddon.fit).toHaveBeenCalled() expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2') - expect(transport.resize).toHaveBeenCalledWith(65, 63) + expect(transport.resize).toHaveBeenCalledWith(65, 63, { claim: true }) }) it('skips foreground grid drift repair while mobile owns the PTY without a fit override', async () => { @@ -18384,6 +18395,56 @@ describe('connectPanePty', () => { } }) + it('updates the claiming desktop xterm before forwarding an observed viewport claim', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + + observer.trigger() + await flushAsyncTicks() + + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledTimes(1) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') const pane = createPane(2) @@ -18444,7 +18505,7 @@ describe('connectPanePty', () => { binding.noteVisibilityResume() await flushAsyncTicks() - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('queues re-asserted resizes while pane resize holds are active', async () => { @@ -18486,7 +18547,7 @@ describe('connectPanePty', () => { release.flush() expect(transport.resize).toHaveBeenCalledTimes(1) - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) } finally { globalThis.CustomEvent = originalCustomEvent } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 9e313fa50bd..8399d469d08 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -46,6 +46,7 @@ import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observe import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' +import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' import { @@ -981,6 +982,7 @@ export function connectPanePty( // flips, exit, dispose) run before/after it exists, so start with no-ops. let syncHiddenRendererPtyDelivery: () => void = () => {} let releaseHiddenRendererPtyDelivery: () => void = () => {} + let suppressViewportClaimTerminalResize = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -3302,6 +3304,26 @@ export function connectPanePty( sendDesktopQueryReplyImmediate ) + const claimViewportForUserActivity = (): void => { + const currentPtyId = transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') { + return + } + let proposed: { cols: number; rows: number } | undefined + try { + proposed = pane.fitAddon.proposeDimensions() + } catch { + proposed = undefined + } + const cols = proposed?.cols ?? pane.terminal.cols + const rows = proposed?.rows ?? pane.terminal.rows + if (cols > 0 && rows > 0) { + // Why: queuing a claim is not convergence. Keep the pane parked until the + // runtime confirms desktop-fit so a transient resize failure retries. + transport.claimViewport?.(cols, rows) + } + } + const onDataDisposable = pane.terminal.onData((data) => { // Why: xterm auto-replies to embedded query sequences (DA1, DECRQM, // OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes @@ -3357,6 +3379,7 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + claimViewportForUserActivity() if (acknowledgedIntent === 'ctrl-c') { // Why: the accepted-write callback is async; let the next command be // inferred if the user cancelled an oversized line and immediately typed. @@ -3382,6 +3405,7 @@ export function connectPanePty( return } if (intent) { + claimViewportForUserActivity() if (transport.sendInput(data)) { markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) @@ -3390,6 +3414,7 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } + claimViewportForUserActivity() if (transport.sendInput(data)) { markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) @@ -3435,7 +3460,7 @@ export function connectPanePty( if (queuePanePtyResizeIfHeld(pane.container, cols, rows)) { return } - transport.resize(cols, rows) + transport.resize(cols, rows, { claim: true }) } const onHeldPtyResizeFlush = (event: Event): void => { @@ -3448,7 +3473,7 @@ export function connectPanePty( pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { - if (suppressSnapshotReplayPtyResize) { + if (suppressSnapshotReplayPtyResize || suppressViewportClaimTerminalResize) { return } forwardPtyResize(cols, rows) @@ -3534,14 +3559,41 @@ export function connectPanePty( // the PTY's applied size; mobile-fit panes only report desktop geometry so // the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md. let pendingGeometryReportRaf: number | null = null + let lastObservedDesktopGrid: { cols: number; rows: number } | null = null + const readPaneSize = (): { width: number; height: number } | null => { + if (typeof pane.container.getBoundingClientRect !== 'function') { + return null + } + const rect = pane.container.getBoundingClientRect() + return { width: rect.width, height: rect.height } + } + let lastObservedPaneSize = readPaneSize() + let pendingPaneGeometryChanged = false const handleObservedPaneGeometry = (): void => { pendingGeometryReportRaf = null + const paneGeometryChanged = pendingPaneGeometryChanged + pendingPaneGeometryChanged = false const currentPtyId = transport.getPtyId() if (!currentPtyId) { + // Why: ResizeObserver may deliver its initial measurement before the + // remote binding completes; retain that passive baseline for the first + // real focused resize instead of swallowing the user's first claim. + const proposed = readProposedTerminalGrid() + if (proposed) { + lastObservedDesktopGrid = proposed + } return } const fitOverride = getFitOverrideForPty(currentPtyId) if (!fitOverride) { + if (pane.terminal.cols > 0 && pane.terminal.rows > 0) { + // Why: record the local grid before a later remote hold parks xterm; + // the first real window/split resize can then claim immediately. + lastObservedDesktopGrid = { + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + } if (shouldSuppressDesktopPtyResize()) { return } @@ -3559,6 +3611,33 @@ export function connectPanePty( if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { return } + const priorProposed = lastObservedDesktopGrid + lastObservedDesktopGrid = proposed + if (fitOverride.mode === 'remote-desktop-fit') { + if ( + shouldClaimRemoteDesktopViewport({ + holdMode: fitOverride.mode, + prior: priorProposed, + current: proposed, + paneGeometryChanged, + paneVisible: deps.isVisibleRef.current, + documentVisible: document.visibilityState !== 'hidden', + documentFocused: document.hasFocus() + }) + ) { + // Why: a focused, visible layout change is genuine activity; release + // the park and update xterm before claiming so the owner does not keep + // rendering the prior owner's stale grid. + suppressViewportClaimTerminalResize = true + try { + pane.terminal.resize(proposed.cols, proposed.rows) + } finally { + suppressViewportClaimTerminalResize = false + } + transport.resize(proposed.cols, proposed.rows, { claim: true }) + } + return + } if (isRemoteRuntimePtyId(currentPtyId)) { transport.resize(proposed.cols, proposed.rows) } else { @@ -3569,6 +3648,16 @@ export function connectPanePty( typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => { + const paneSize = readPaneSize() + if ( + paneSize && + lastObservedPaneSize && + (paneSize.width !== lastObservedPaneSize.width || + paneSize.height !== lastObservedPaneSize.height) + ) { + pendingPaneGeometryChanged = true + } + lastObservedPaneSize = paneSize if (pendingGeometryReportRaf !== null) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 40892001963..7f0f4876eba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -101,10 +101,17 @@ export type PtyTransport = { // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise + claimViewport?: (cols: number, rows: number) => boolean resize: ( cols: number, rows: number, - meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } + meta?: { + widthPx?: number + heightPx?: number + cellW?: number + cellH?: number + claim?: boolean + } ) => boolean isConnected: () => boolean getPtyId: () => string | null diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 70a6071b0e6..60656f2907f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -976,11 +976,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }), - resize(cols: number, rows: number): boolean { + claimViewport(cols: number, rows: number): boolean { if (!connected || !ptyId) { return false } - window.api.pty.resize(ptyId, cols, rows) + window.api.pty.claimViewport(ptyId, cols, rows) + return true + }, + + resize(cols: number, rows: number, meta): boolean { + if (!connected || !ptyId) { + return false + } + if (meta?.claim) { + window.api.pty.resize(ptyId, cols, rows) + window.api.pty.claimViewport(ptyId, cols, rows) + } else { + window.api.pty.resize(ptyId, cols, rows) + } return true }, diff --git a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts new file mode 100644 index 00000000000..a135ca247e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' + +describe('shouldClaimRemoteDesktopViewport', () => { + it('requires a second, changed measurement from a focused visible pane', () => { + const current = { cols: 100, rows: 30 } + expect( + shouldClaimRemoteDesktopViewport({ + holdMode: 'remote-desktop-fit', + prior: null, + current, + paneGeometryChanged: false, + paneVisible: true, + documentVisible: true, + documentFocused: true + }) + ).toBe(false) + expect( + shouldClaimRemoteDesktopViewport({ + holdMode: 'remote-desktop-fit', + prior: { cols: 90, rows: 30 }, + current, + paneGeometryChanged: false, + paneVisible: true, + documentVisible: true, + documentFocused: true + }) + ).toBe(true) + }) + + it.each([ + { paneVisible: false, documentVisible: true, documentFocused: true }, + { paneVisible: true, documentVisible: false, documentFocused: true }, + { paneVisible: true, documentVisible: true, documentFocused: false } + ])('rejects passive background geometry: %o', (visibility) => { + expect( + shouldClaimRemoteDesktopViewport({ + holdMode: 'remote-desktop-fit', + prior: { cols: 90, rows: 30 }, + current: { cols: 100, rows: 30 }, + paneGeometryChanged: false, + ...visibility + }) + ).toBe(false) + }) + + it('accepts the first focused measurement after the observed pane box changed', () => { + expect( + shouldClaimRemoteDesktopViewport({ + holdMode: 'remote-desktop-fit', + prior: null, + current: { cols: 70, rows: 30 }, + paneGeometryChanged: true, + paneVisible: true, + documentVisible: true, + documentFocused: true + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts new file mode 100644 index 00000000000..bb057d0b5b5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts @@ -0,0 +1,23 @@ +import type { FitHoldMode } from '@/lib/pane-manager/mobile-fit-overrides' + +type TerminalGrid = { cols: number; rows: number } + +export function shouldClaimRemoteDesktopViewport(args: { + holdMode: FitHoldMode + prior: TerminalGrid | null + current: TerminalGrid + paneGeometryChanged: boolean + paneVisible: boolean + documentVisible: boolean + documentFocused: boolean +}): boolean { + return Boolean( + args.holdMode === 'remote-desktop-fit' && + (args.paneGeometryChanged || + (args.prior && + (args.prior.cols !== args.current.cols || args.prior.rows !== args.current.rows))) && + args.paneVisible && + args.documentVisible && + args.documentFocused + ) +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index de741deedeb..59d23ca43b3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -38,6 +38,7 @@ describe('createRemoteRuntimePtyTransport', () => { terminal: string client: { id: string; type: string } viewport?: { cols: number; rows: number } + capabilities?: { desktopViewportClaims?: 1 } } { const frames = subscriptionSendBinary.mock.calls .map((call) => decodeTerminalStreamFrame(call[0])) @@ -51,6 +52,7 @@ describe('createRemoteRuntimePtyTransport', () => { terminal: string client: { id: string; type: string } viewport?: { cols: number; rows: number } + capabilities?: { desktopViewportClaims?: 1 } }>(frame.payload) if (!payload) { throw new Error('invalid terminal subscribe payload') @@ -166,6 +168,12 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onError).not.toHaveBeenCalled() expect(transport.getPtyId()).toBe('remote:terminal-1') + await vi.waitFor(() => + expect(latestSubscribePayload().capabilities).toEqual({ + ackOutput: 1, + desktopViewportClaims: 1 + }) + ) expect(runtimeSubscribe).toHaveBeenCalledWith( expect.objectContaining({ selector: 'env-1', @@ -177,11 +185,78 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-1', - client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }, + client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' }, viewport: { cols: 120, rows: 40 } }) }) + it('parks passive peers when another remote desktop owns the grid', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { getFitOverrideForPty, setFitOverride } = + await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + await transport.connect({ url: '', cols: 120, rows: 40, callbacks: {} }) + const { streamId } = latestSubscribePayload() + const ptyId = transport.getPtyId() + expect(ptyId).not.toBeNull() + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'fit-override-changed', + streamId, + mode: 'remote-desktop-fit', + cols: 96, + rows: 32 + } + }) + + expect(ptyId ? getFitOverrideForPty(ptyId) : null).toEqual({ + mode: 'remote-desktop-fit', + cols: 96, + rows: 32 + }) + if (ptyId) { + setFitOverride(ptyId, 'desktop-fit', 0, 0) + } + }) + + it('gives separate paired viewers of the same host pane distinct refresh identities', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const first = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const second = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + first.attach({ existingPtyId: 'remote:terminal-1', cols: 80, rows: 24, callbacks: {} }) + second.attach({ existingPtyId: 'remote:terminal-1', cols: 120, rows: 40, callbacks: {} }) + + await vi.waitFor(() => { + const subscribeFrames = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + expect(subscribeFrames).toHaveLength(2) + const clientIds = subscribeFrames.map((frame) => { + const payload = frame + ? decodeTerminalStreamJson<{ client: { id: string } }>(frame.payload) + : null + return payload?.client.id + }) + expect(clientIds[0]).toMatch(/^desktop:tab-1:pane:1:/) + expect(clientIds[1]).toMatch(/^desktop:tab-1:pane:1:/) + expect(clientIds[0]).not.toBe(clientIds[1]) + }) + + first.destroy?.() + second.destroy?.() + }) + it('routes encoded restored terminal ids to their owning runtime environment', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-2', { @@ -593,7 +668,9 @@ describe('createRemoteRuntimePtyTransport', () => { params: { terminal: 'terminal-new', text: 'x', - client: { id: 'desktop:tab-1:pane:1', type: 'desktop' } + client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' }, + viewport: { cols: 80, rows: 24 }, + claimViewport: true }, timeoutMs: 15_000 }) @@ -1363,6 +1440,83 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) }) + it('releases pending claimed input when reconnect subscription fails', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + await transport.connect({ url: '', callbacks: { onError } }) + let rejectReconnect = (_error: Error): void => {} + runtimeSubscribe.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectReconnect = reject + }) + ) + + subscriptionCallbacks?.onClose?.() + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + expect(transport.claimViewport?.(101, 33)).toBe(true) + const accepted = transport.sendInputAccepted?.('\x03') + await Promise.resolve() + rejectReconnect(new Error('reconnect failed')) + + await expect(accepted).resolves.toBe(false) + expect(onError).toHaveBeenCalledWith('reconnect failed') + }) + + it('releases pending claimed input when the remote terminal ends', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + + expect(transport.claimViewport?.(101, 33)).toBe(true) + const accepted = transport.sendInputAccepted?.('x') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'end', streamId } + }) + + await expect(accepted).resolves.toBe(false) + }) + + it('retries when a replacement transport closes before its stream installs', async () => { + const transportCallbacks: NonNullable[] = [] + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: NonNullable) => { + transportCallbacks.push(callbacks) + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const connected = transport.connect({ url: '', callbacks: {} }) + await vi.waitFor(() => expect(transportCallbacks).toHaveLength(1)) + transportCallbacks[0].onResponse({ ok: true, result: { type: 'ready' } }) + await connected + + transportCallbacks[0].onClose?.() + await vi.waitFor(() => expect(transportCallbacks).toHaveLength(2)) + transportCallbacks[1].onResponse({ ok: true, result: { type: 'ready' } }) + transportCallbacks[1].onClose?.() + + await vi.waitFor(() => expect(transportCallbacks).toHaveLength(3)) + transport.destroy?.() + }) + it('resubscribes with the latest pane viewport after the remote stream closes', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { @@ -1384,6 +1538,110 @@ describe('createRemoteRuntimePtyTransport', () => { }) }) + it('replays a viewport that changed during the subscribe round-trip once the stream is current', async () => { + // Why: a resize landing while the subscribe is in flight takes the one-shot + // RPC fallback, which is refresh-only (no leak) and no-ops before the stream + // floor exists. The transport must replay the latest viewport over the + // now-current stream so the PTY does not stall at the subscribe-time width. + // Hold the multiplex "ready" to keep the round-trip open across the resize. + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + // Drain microtasks WITHOUT advancing timers, so the 33ms viewport batcher + // cannot fire — the replayed Resize frame must come from the round-trip + // flush alone (this test fails if that flush is removed). + const flushMicrotasks = async (): Promise => { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } + } + + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + + // Resize while the stream is not yet current (subscribe still pending). + expect(transport.resize(132, 43)).toBe(true) + + // Release readiness and drain the resolution chain by microtasks only. + emitMultiplexReady() + await flushMicrotasks() + + // The Subscribe frame still carries the subscribe-time viewport... + expect(latestSubscribePayload().viewport).toEqual({ cols: 80, rows: 24 }) + // ...and the newer viewport is replayed as a Resize frame over the stream, + // before the batcher's 33ms timer could have produced it. + const resizeFrame = latestFrameForOpcode(TerminalStreamOpcode.Resize) + expect(resizeFrame && decodeTerminalStreamJson(resizeFrame.payload)).toEqual({ + cols: 132, + rows: 43 + }) + + transport.destroy?.() + }) + + it('replays a claim before input typed during the subscribe round-trip', async () => { + vi.useFakeTimers() + try { + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:terminal-1', + cols: 80, + rows: 24, + callbacks: {} + }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) + expect(transport.claimViewport?.(101, 33)).toBe(true) + expect(transport.sendInput('x')).toBe(true) + await vi.advanceTimersByTimeAsync(8) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.send' }) + ) + + emitMultiplexReady() + await vi.waitFor(() => { + const opcodes = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])?.opcode) + .filter((opcode) => opcode !== undefined) + expect(opcodes).toEqual([ + TerminalStreamOpcode.Subscribe, + TerminalStreamOpcode.ClaimViewport, + TerminalStreamOpcode.Resize, + TerminalStreamOpcode.Input + ]) + }) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + it('coalesces rapid remote terminal input before sending it to the runtime', async () => { vi.useFakeTimers() try { @@ -1512,7 +1770,9 @@ describe('createRemoteRuntimePtyTransport', () => { params: { terminal: 'terminal-1', text: '\x03', - client: { id: 'desktop:tab-1:pane:1', type: 'desktop' } + client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' }, + viewport: { cols: 80, rows: 24 }, + claimViewport: true }, timeoutMs: 15_000 }) @@ -1553,7 +1813,9 @@ describe('createRemoteRuntimePtyTransport', () => { params: { terminal: 'terminal-1', text: 'a\x03', - client: { id: 'desktop:tab-1:pane:1', type: 'desktop' } + client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' }, + viewport: { cols: 80, rows: 24 }, + claimViewport: true }, timeoutMs: 15_000 }) @@ -1801,6 +2063,42 @@ describe('createRemoteRuntimePtyTransport', () => { } }) + it('sends an activity claim before the user input it sizes', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ url: '', callbacks: {} }) + const { streamId } = latestSubscribePayload() + subscriptionSendBinary.mockClear() + + expect(transport.claimViewport?.(101, 33)).toBe(true) + expect(transport.sendInput('x')).toBe(true) + await vi.runOnlyPendingTimersAsync() + + const frames = subscriptionSendBinary.mock.calls.map((call) => + decodeTerminalStreamFrame(call[0]) + ) + expect(frames.map((frame) => frame?.opcode)).toEqual([ + TerminalStreamOpcode.ClaimViewport, + TerminalStreamOpcode.Resize, + TerminalStreamOpcode.Input + ]) + expect(frames[0]?.streamId).toBe(streamId) + expect(frames[0] ? decodeTerminalStreamJson(frames[0].payload) : null).toEqual({ + cols: 101, + rows: 33 + }) + } finally { + vi.useRealTimers() + } + }) + it('replays remote scrollback through the parser without firing stale attention events', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index b4eea73e0d7..78d78dfefcb 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -32,6 +32,7 @@ import { createRemoteRuntimePtyTextBatcher, createRemoteRuntimeViewportBatcher } from './remote-runtime-pty-batching' +import { createBrowserUuid } from '@/lib/browser-uuid' import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides' import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id' @@ -89,7 +90,22 @@ export function createRemoteRuntimePtyTransport( let desiredViewport: { cols: number; rows: number } | null = null let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribing = false - const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}` + let resubscribeRequested = false + let subscriptionGeneration = 0 + let pendingViewportClaim = false + let pendingClaimInput = '' + const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>() + const clearPendingViewportClaim = (): void => { + pendingViewportClaim = false + pendingClaimInput = '' + for (const resolve of viewportClaimReadyWaiters) { + resolve(false) + } + viewportClaimReadyWaiters.clear() + } + // Why: tab/leaf ids identify the mirrored host pane, so every paired viewer + // shares them. The instance suffix keeps one viewer's refresh off peer records. + const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -262,6 +278,14 @@ export function createRemoteRuntimePtyTransport( if (!connected || handle !== targetHandle) { return false } + if (pendingViewportClaim && !getCurrentMultiplexedStream(targetHandle)) { + const ready = await new Promise((resolve) => { + viewportClaimReadyWaiters.add(resolve) + }) + if (!ready || !connected || handle !== targetHandle) { + return false + } + } // Why: normal remote sendInput may be waiting on yielded size validation; // drain it before acknowledged writes so terminal bytes stay ordered. const text = `${inputBatcher.takePending()}${data}` @@ -283,7 +307,8 @@ export function createRemoteRuntimePtyTransport( const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', { terminal: targetHandle, text: chunk, - client: { id: clientId, type: 'desktop' } + client: { id: clientId, type: 'desktop' }, + ...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {}) }) if (result.send.accepted !== true) { return false @@ -303,30 +328,46 @@ export function createRemoteRuntimePtyTransport( if (!connected || !targetHandle) { return } - if (getCurrentMultiplexedStream(targetHandle)?.sendInput(text)) { + const stream = getCurrentMultiplexedStream(targetHandle) + if (stream?.sendInput(text)) { + return + } + if (pendingViewportClaim) { + // Why: a claim during subscribe/reconnect has no stream record to own + // yet. Hold its input until the stream can emit claim+input in one order. + pendingClaimInput += text return } void callRuntime('terminal.send', { terminal: targetHandle, text, - client: { id: clientId, type: 'desktop' } + client: { id: clientId, type: 'desktop' }, + ...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {}) }).catch((error) => { handleRemoteTerminalError(error) }) }) - function sendViewportUpdate(cols: number, rows: number): void { + function sendViewportUpdate(cols: number, rows: number, claim = false): void { const targetHandle = handle if (!connected || !targetHandle) { return } - if (getCurrentMultiplexedStream(targetHandle)?.resize(cols, rows)) { + const stream = getCurrentMultiplexedStream(targetHandle) + if (claim ? stream?.claimViewport(cols, rows) : stream?.resize(cols, rows)) { + if (claim) { + pendingViewportClaim = false + } return } + if (claim) { + pendingViewportClaim = true + } void callRuntime('terminal.updateViewport', { terminal: targetHandle, client: { id: clientId, type: 'desktop' }, - viewport: { cols, rows } + viewport: { cols, rows }, + ...(claim ? { claim: true } : {}) }).catch(() => {}) } @@ -363,6 +404,7 @@ export function createRemoteRuntimePtyTransport( function retireRemoteTerminalId(): void { connected = false + clearPendingViewportClaim() const stalePtyId = remotePtyId handle = null remotePtyId = null @@ -414,20 +456,55 @@ export function createRemoteRuntimePtyTransport( await subscribeToHandle() } + function scheduleResubscribeAfterTransportClose(): void { + if (destroyed || !connected || !handle) { + return + } + if (resubscribing) { + resubscribeRequested = true + return + } + resubscribing = true + const resubscribeHandle = handle + void resubscribeAfterTransportClose(resubscribeHandle) + .catch((error) => { + if (!destroyed && connected && handle) { + clearPendingViewportClaim() + handleRemoteTerminalError(error) + } + }) + .finally(() => { + resubscribing = false + if (resubscribeRequested) { + resubscribeRequested = false + scheduleResubscribeAfterTransportClose() + } + }) + } + async function subscribeToHandle(): Promise { if (!handle) { return } const subscribedHandle = handle const subscribedPtyId = remotePtyId + const generation = ++subscriptionGeneration + let transportClosed = false + // Why: the viewport we hand the subscribe request. A resize landing during + // the round-trip falls back to the one-shot RPC, which is refresh-only (no + // leak) and no-ops before the stream record exists — so replay the latest + // remembered viewport through the stream once it's current (below). + const subscribedViewport = desiredViewport const isCurrentSubscription = (): boolean => + !transportClosed && + generation === subscriptionGeneration && isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId) const nextStream = await getRemoteRuntimeTerminalMultiplexer( currentRuntimeEnvironmentId ).subscribeTerminal({ terminal: subscribedHandle, client: { id: clientId, type: 'desktop' }, - viewport: desiredViewport ?? undefined, + viewport: subscribedViewport ?? undefined, callbacks: { onData: (data, meta) => { if (isCurrentSubscription()) { @@ -464,6 +541,7 @@ export function createRemoteRuntimePtyTransport( remotePtyId = null multiplexedStream = null multiplexedStreamHandle = null + clearPendingViewportClaim() storedCallbacks.onExit?.(0) storedCallbacks.onDisconnect?.() if (subscribedPtyId) { @@ -486,35 +564,59 @@ export function createRemoteRuntimePtyTransport( } }, onTransportClose: () => { - if (!isCurrentSubscription()) { + transportClosed = true + if (generation !== subscriptionGeneration) { return } + if (!isCurrentSubscription()) { + // isCurrentSubscription excludes the just-closed stream by design. + if (!isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId)) { + return + } + } multiplexedStream = null multiplexedStreamHandle = null - if (destroyed || !connected || !handle || resubscribing) { - return - } - resubscribing = true - const resubscribeHandle = handle - void resubscribeAfterTransportClose(resubscribeHandle) - .catch((error) => { - if (!destroyed && connected && handle) { - handleRemoteTerminalError(error) - } - }) - .finally(() => { - resubscribing = false - }) + scheduleResubscribeAfterTransportClose() } } }) - if (destroyed || !connected || handle !== subscribedHandle || remotePtyId !== subscribedPtyId) { + if ( + transportClosed || + generation !== subscriptionGeneration || + destroyed || + !connected || + handle !== subscribedHandle || + remotePtyId !== subscribedPtyId + ) { nextStream.close() return } closeMultiplexedStream() multiplexedStream = nextStream multiplexedStreamHandle = subscribedHandle + // Why: a viewport change that landed during the subscribe round-trip took + // the now-no-op one-shot fallback, so the stream record is still at the + // subscribe-time size. Replay the latest remembered viewport so the PTY + // tracks the current width instead of stalling until the next resize. + if (pendingViewportClaim && desiredViewport) { + nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows) + pendingViewportClaim = false + const queuedInput = pendingClaimInput + pendingClaimInput = '' + if (queuedInput) { + nextStream.sendInput(queuedInput) + } + for (const resolve of viewportClaimReadyWaiters) { + resolve(true) + } + viewportClaimReadyWaiters.clear() + } else if ( + desiredViewport && + (desiredViewport.cols !== subscribedViewport?.cols || + desiredViewport.rows !== subscribedViewport?.rows) + ) { + nextStream.resize(desiredViewport.cols, desiredViewport.rows) + } } return { @@ -618,6 +720,7 @@ export function createRemoteRuntimePtyTransport( if (handle === targetHandle && multiplexedStreamHandle !== targetHandle) { closeMultiplexedStream() } + clearPendingViewportClaim() handleRemoteTerminalError(error) }) }, @@ -631,6 +734,7 @@ export function createRemoteRuntimePtyTransport( return } connected = false + clearPendingViewportClaim() const id = remotePtyId closeMultiplexedStream() handle = null @@ -647,6 +751,7 @@ export function createRemoteRuntimePtyTransport( viewportBatcher.flush() outputProcessor.clearAccumulatedState() connected = false + clearPendingViewportClaim() closeMultiplexedStream() storedCallbacks = {} }, @@ -696,10 +801,15 @@ export function createRemoteRuntimePtyTransport( if (stream?.sendInput(text)) { return true } + if (pendingViewportClaim) { + pendingClaimInput += text + return true + } void callRuntime('terminal.send', { terminal: targetHandle, text, - client: { id: clientId, type: 'desktop' } + client: { id: clientId, type: 'desktop' }, + ...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {}) }).catch((error) => { handleRemoteTerminalError(error) }) @@ -708,11 +818,26 @@ export function createRemoteRuntimePtyTransport( sendInputAccepted: sendInputAcceptedToRuntime, - resize(cols: number, rows: number): boolean { + claimViewport(cols: number, rows: number): boolean { if (!connected || !handle) { return false } rememberViewport(cols, rows) + viewportBatcher.clear() + sendViewportUpdate(cols, rows, true) + return true + }, + + resize(cols: number, rows: number, meta): boolean { + if (!connected || !handle) { + return false + } + rememberViewport(cols, rows) + if (meta?.claim) { + viewportBatcher.clear() + sendViewportUpdate(cols, rows, true) + return true + } // Why: xterm fit can emit resize bursts while the user drags panes or // restores layouts. Remote runtimes only need the last viewport in a frame. viewportBatcher.queue(cols, rows) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d65f73c1fab..aadcc09e230 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3228,7 +3228,7 @@ export function useIpcEvents(): void { kind: 'fit' event: { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number } diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts index 69dee772077..9d264c4eacc 100644 --- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts @@ -8,7 +8,8 @@ import { getPaneIdsForPty, onOverrideChange, hydrateOverrides, - getAllOverrides + getAllOverrides, + getMobileFitOverridePtyIds } from './mobile-fit-overrides' afterEach(() => { @@ -36,6 +37,18 @@ describe('setFitOverride / getFitOverrideForPty', () => { expect(override).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 }) }) + it('stores and releases a remote desktop fit hold', () => { + setFitOverride('pty-remote', 'remote-desktop-fit', 96, 32) + expect(getFitOverrideForPty('pty-remote')).toEqual({ + mode: 'remote-desktop-fit', + cols: 96, + rows: 32 + }) + + setFitOverride('pty-remote', 'desktop-fit', 120, 40) + expect(getFitOverrideForPty('pty-remote')).toBeNull() + }) + it('removes the override when mode is desktop-fit', () => { setFitOverride('pty-1', 'mobile-fit', 49, 20) setFitOverride('pty-1', 'desktop-fit', 120, 40) @@ -323,6 +336,13 @@ describe('getAllOverrides', () => { all.delete('pty-1') expect(getFitOverrideForPty('pty-1')).not.toBeNull() }) + + it('excludes remote desktop holds from mobile bulk restore', () => { + setFitOverride('pty-mobile', 'mobile-fit', 49, 20) + setFitOverride('pty-remote', 'remote-desktop-fit', 100, 30) + + expect(getMobileFitOverridePtyIds()).toEqual(['pty-mobile']) + }) }) // --------------------------------------------------------------------------- diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts index 3ab4937c4d2..fc287f45663 100644 --- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts +++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts @@ -1,10 +1,10 @@ -// Why: mobile-fit overrides are runtime-owned state that the renderer must -// respect. When a mobile client resizes a PTY to phone dimensions, the desktop -// renderer must not auto-fit that PTY back to desktop size. This module stores -// the override state and provides lookup for safeFit() and transport.resize(). +// Why: a phone or another desktop may own the shared PTY grid. Non-owners park +// xterm at that authoritative size so passive fitting cannot start a resize war. + +export type FitHoldMode = 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' type FitOverride = { - mode: 'mobile-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' cols: number rows: number } @@ -24,7 +24,7 @@ function fitBindingKey(tabId: string, paneId: number): string { // and trigger safeFit on affected panes. type OverrideChangeEvent = { ptyId: string - mode: 'mobile-fit' | 'desktop-fit' + mode: FitHoldMode cols: number rows: number // Why: the dimensions the PTY was at *before* this event fired. For a @@ -49,14 +49,9 @@ function notifyChange(event: OverrideChangeEvent): void { } } -export function setFitOverride( - ptyId: string, - mode: 'mobile-fit' | 'desktop-fit', - cols: number, - rows: number -): void { +export function setFitOverride(ptyId: string, mode: FitHoldMode, cols: number, rows: number): void { const prior = overridesByPtyId.get(ptyId) ?? null - if (mode === 'mobile-fit') { + if (mode === 'mobile-fit' || mode === 'remote-desktop-fit') { overridesByPtyId.set(ptyId, { mode, cols, rows }) } else { overridesByPtyId.delete(ptyId) @@ -117,7 +112,12 @@ export function unbindPane(paneId: number, tabId?: string): void { } export function hydrateOverrides( - overrides: { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + overrides: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' + cols: number + rows: number + }[] ): void { const previous = new Map(overridesByPtyId) overridesByPtyId.clear() @@ -131,7 +131,7 @@ export function hydrateOverrides( const prior = previous.get(ptyId) ?? null notifyChange({ ptyId, - mode: 'mobile-fit', + mode: override.mode, cols: override.cols, rows: override.rows, priorCols: prior?.cols ?? null, @@ -155,3 +155,9 @@ export function hydrateOverrides( export function getAllOverrides(): Map { return new Map(overridesByPtyId) } + +export function getMobileFitOverridePtyIds(): string[] { + return [...overridesByPtyId].flatMap(([ptyId, override]) => + override.mode === 'mobile-fit' ? [ptyId] : [] + ) +} diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index c5951458020..abe054b80c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -252,6 +252,22 @@ describe('safeFit', () => { expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20) }) + it('parks xterm at a remote desktop owner grid', () => { + const pane = createPane({ + proposedCols: 120, + proposedRows: 40, + terminalCols: 120, + terminalRows: 40 + }) + pane.container.dataset.ptyId = 'pty-remote' + setFitOverride('pty-remote', 'remote-desktop-fit', 96, 32) + + safeFit(pane) + + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 32) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('skips resize when terminal already matches override dimensions', () => { const pane = createPane({ proposedCols: 120, diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index e854a5e7efc..7adf6b5dcf2 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -25,7 +25,7 @@ type TerminalMultiplexEvent = | { type: 'fit-override-changed' streamId: number - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number } @@ -43,7 +43,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { onEnd?: () => void onError?: (message: string) => void onFitOverrideChanged?: (event: { - mode: 'mobile-fit' | 'desktop-fit' + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void @@ -57,6 +57,7 @@ export type RemoteRuntimeMultiplexedTerminal = { streamId: number sendInput: (text: string) => boolean resize: (cols: number, rows: number) => boolean + claimViewport: (cols: number, rows: number) => boolean serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{ data: string cols: number @@ -247,6 +248,22 @@ class RemoteRuntimeTerminalMultiplexer { TerminalStreamOpcode.Resize, encodeTerminalStreamJson({ cols, rows }) ), + claimViewport: (cols, rows) => { + const claimed = this.sendFrame( + streamId, + TerminalStreamOpcode.ClaimViewport, + encodeTerminalStreamJson({ cols, rows }) + ) + // Why: older runtimes ignore the claim opcode but still understand + // Resize. Claim first keeps new-runtime ownership precise and leaves a + // backwards-compatible resize immediately behind it. + const resized = this.sendFrame( + streamId, + TerminalStreamOpcode.Resize, + encodeTerminalStreamJson({ cols, rows }) + ) + return claimed && resized + }, serializeBuffer: (opts) => this.requestSnapshot(state, opts), close: () => { if (this.streams.get(streamId) === state) { @@ -271,7 +288,8 @@ class RemoteRuntimeTerminalMultiplexer { terminal: args.terminal, client: args.client, viewport: args.viewport, - capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined + capabilities: + args.client.type === 'desktop' ? { ackOutput: 1, desktopViewportClaims: 1 } : undefined }) ) if (!sent) { @@ -389,7 +407,9 @@ class RemoteRuntimeTerminalMultiplexer { ) } else if (event.type === 'fit-override-changed') { if ( - (event.mode !== 'mobile-fit' && event.mode !== 'desktop-fit') || + (event.mode !== 'mobile-fit' && + event.mode !== 'remote-desktop-fit' && + event.mode !== 'desktop-fit') || typeof event.cols !== 'number' || typeof event.rows !== 'number' ) { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 79166f666d2..776c080e9bc 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -100,11 +100,15 @@ describe('remote runtime terminal data subscriptions', () => { expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) const subscribePayload = subscribeFrame && - decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>( - subscribeFrame.payload - ) + decodeTerminalStreamJson<{ + streamId: number + capabilities?: { ackOutput?: 1; desktopViewportClaims?: 1 } + }>(subscribeFrame.payload) expect(subscribePayload?.streamId).toEqual(expect.any(Number)) - expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 }) + expect(subscribePayload?.capabilities).toEqual({ + ackOutput: 1, + desktopViewportClaims: 1 + }) callbacks?.onBinary?.( encodeTerminalStreamFrame({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 321940a15c8..5e7c61a12ff 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2713,6 +2713,7 @@ function createPtyApi(): NonNullable['pty']> { write: () => {}, writeAccepted: () => Promise.resolve(false), resize: () => {}, + claimViewport: () => {}, reportGeometry: () => {}, signal: () => {}, // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 0011463babe..02314fb39e4 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -17,7 +17,10 @@ export enum TerminalStreamOpcode { Metadata = 12, // Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only // remote-multiplex flow control) renumbers to stay wire-compatible. - Ack = 13 + Ack = 13, + // Why 14: Ack already occupies 13 on current clients; older runtimes ignore + // this opcode and still receive the compatibility Resize frame behind it. + ClaimViewport = 14 } export type TerminalStreamFrame = { @@ -98,6 +101,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Unsubscribe || value === TerminalStreamOpcode.SnapshotRequest || value === TerminalStreamOpcode.Metadata || - value === TerminalStreamOpcode.Ack + value === TerminalStreamOpcode.Ack || + value === TerminalStreamOpcode.ClaimViewport ) } From 25ecf2eea22e21fd839b877ad0c9decb98872aaa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:20:55 -0700 Subject: [PATCH 067/138] fix: reconcile SSH repo rows after host re-add (#8201) Co-authored-by: Orca --- ...2026-07-10-ssh-repo-host-reconciliation.md | 131 ++++++++ src/main/ipc/ssh.test.ts | 33 +- src/main/ipc/ssh.ts | 21 +- src/main/persistence.test.ts | 16 +- src/main/persistence.ts | 18 +- src/main/ssh/ssh-connection-store.test.ts | 46 ++- src/main/ssh/ssh-connection-store.ts | 17 +- src/main/ssh/ssh-target-readoption.test.ts | 44 +-- src/main/ssh/ssh-target-readoption.ts | 25 +- src/preload/api-types.ts | 6 +- src/preload/index.ts | 6 +- .../src/components/settings/SshPane.tsx | 23 +- .../sidebar/AddRemoteHostDialog.tsx | 8 +- .../readopted-ssh-worktree-rows.test.ts | 45 +++ .../slices/readopted-ssh-worktree-rows.ts | 52 ++++ .../slices/repos-all-hosts-generation.test.ts | 114 +++++++ .../src/store/slices/repos-all-hosts.test.ts | 80 +++++ .../repos-ssh-host-reconciliation.test.ts | 291 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 248 +++++++++++---- .../slices/superseded-ssh-repo-rows.test.ts | 93 ++++-- .../store/slices/superseded-ssh-repo-rows.ts | 110 ++++--- src/renderer/src/store/slices/worktrees.ts | 86 ++++-- src/renderer/src/web/web-preload-api.ts | 2 +- src/shared/ssh-types.ts | 17 + .../e2e/ssh-codex-reconnect-replay-driver.ts | 3 +- tests/e2e/ssh-docker-relay-perf.spec.ts | 3 +- tests/e2e/ssh-localhost.spec.ts | 3 +- .../e2e/ssh-pi-compatible-agent-title.spec.ts | 3 +- 28 files changed, 1304 insertions(+), 240 deletions(-) create mode 100644 docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md create mode 100644 src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts create mode 100644 src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts create mode 100644 src/renderer/src/store/slices/repos-all-hosts-generation.test.ts create mode 100644 src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts diff --git a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md new file mode 100644 index 00000000000..ac3249d306e --- /dev/null +++ b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md @@ -0,0 +1,131 @@ +# SSH Repo Host Reconciliation Design + +Date: 2026-07-10 + +## Problem + +The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the +removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to +the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached +old-target row alongside the fetched new-target row because repo rows are keyed by execution host +and repo ID. + +The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made +worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row +without weakening that boundary. + +## Why UUID Inference Is Unsafe + +The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a +checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it +must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID +does not prove that the SSH row moved. + +Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed +target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target +to the new target. The renderer must consume this evidence instead of inferring migration from live +siblings or SSH target metadata. + +## Ordering Gap + +Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is +possible in renderer state: + +1. The catalog transaction can merge old and new rows before the add response supplies migration + evidence. +2. The add response can supply evidence while renderer state still contains only the old row. + +Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that +point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and +moves cached worktree ownership onto the new host. + +## Non-Goals + +- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs. +- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings. +- Do not remove PR #7997's execution-host context from destructive operations. +- Do not change git-provider behavior or add provider-specific assumptions. + +## Design + +1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count. +2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation. +3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main + still sends `repos:changed` when at least one repo moved. +4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the + invoke resolves. The web preload returns an empty evidence list because target management is + desktop-only. +5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local, + targeted-runtime, and all-host catalog transaction reconciles against the latest pending set. +6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old + direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are + not considered evidence. +7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns + local and direct-SSH setups; `runtime:` setups remain authoritative on the remote + Orca server. +8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH + host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate + is discarded. +9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists. + Repo catalog transactions also reapply pending worktree migration, covering responses that land + after evidence arrives but before the new repo row. + +```text +main: remove target -> tombstone +main: re-add matching identity -> reassign old target ID to new target ID +main: return exact (old target, new target, moved repo IDs) +renderer: retain evidence until catalog includes the exact new SSH owner +renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership +``` + +## Safety Constraints + +- A local or runtime sibling with the same UUID never supersedes an SSH ghost. +- A mapping is not consumed until the exact new direct-SSH row exists. +- Runtime-owned SSH rows and `runtime:` setup rows remain untouched. +- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest + repos and pending evidence. +- Missing, offline, or unhydrated target metadata is not deletion or migration evidence. + +## Test Plan + +- Persistence returns exact migrated repo IDs and leaves other hosts untouched. +- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import. +- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence. +- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime + ownership preservation, and evidence deduplication. +- Store tests cover both event orders: catalog first and evidence first. +- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID. +- Old direct-SSH setups are removed only for proven migrations; runtime setups remain. +- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only + worktree row migrates to the exact new host. +- A deferred old-host worktree response cannot restore old visible or detected ownership after the + new repo catalog consumes the migration evidence. +- Targeted runtime and all-host catalog transactions reconcile against the latest state. + +Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required +by the repository. + +## Electron Validation + +Use an isolated profile and a throwaway Linux SSH target: + +1. Add a repo through the throwaway SSH target. +2. Remove the SSH target and re-add the same identity without restarting the renderer. +3. Confirm exactly one SSH project row remains and its worktree opens. +4. Confirm Explorer lists the remote files without an ambiguous-host routing error. +5. Repeat the cycle and confirm the stale row does not return. +6. Keep an adjacent local project visible and confirm it remains unchanged. + +There is no new control or copy. The behavioral evidence is the stable row, successful workspace +routing, and deterministic store assertions. + +## Performance And Scope + +Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID +once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners, +subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually +migrated during the user-triggered add/import operation. + +Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the +existing strict alias or host/user/port logic, and git-provider identity is not used. diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 0c5a51d9ae2..51801ecdef5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -29,7 +29,12 @@ const { addTarget: vi.fn(), updateTarget: vi.fn(), removeTarget: vi.fn(), - importFromSshConfig: vi.fn().mockReturnValue([]) + importFromSshConfig: vi.fn().mockReturnValue([]), + lastRepoReadoptions: [] as { + oldTargetId: string + newTargetId: string + repoIds: string[] + }[] }, mockConnectionManager: { connect: vi.fn(), @@ -293,6 +298,7 @@ describe('SSH IPC handlers', () => { mockSshStore.updateTarget.mockReset() mockSshStore.removeTarget.mockReset() mockSshStore.importFromSshConfig.mockReset().mockReturnValue([]) + mockSshStore.lastRepoReadoptions = [] mockWindow.webContents.send.mockReset() mockStore.getSshRemotePtyLeases.mockReset().mockReturnValue([]) mockStore.markSshRemotePtyLease.mockReset() @@ -379,7 +385,28 @@ describe('SSH IPC handlers', () => { const result = await handlers.get('ssh:addTarget')!(null, { target: newTarget }) expect(mockSshStore.addTarget).toHaveBeenCalledWith(newTarget) - expect(result).toEqual(withId) + expect(result).toEqual({ target: withId, repoReadoptions: [] }) + }) + + it('ssh:addTarget returns exact re-adoption evidence and refreshes repos', async () => { + const target = { + id: 'ssh-new', + label: 'Server', + host: 'server.example.com', + port: 22, + username: 'deploy' + } + const repoReadoptions = [ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ] + mockSshStore.addTarget.mockReturnValue(target) + mockSshStore.lastRepoReadoptions = repoReadoptions + + const result = await handlers.get('ssh:addTarget')!(null, { target }) + + expect(result).toEqual({ target, repoReadoptions }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + expect(mockSshStore.lastRepoReadoptions).toEqual([]) }) it('ssh:removeTarget calls store.removeTarget', async () => { @@ -438,7 +465,7 @@ describe('SSH IPC handlers', () => { mockSshStore.importFromSshConfig.mockReturnValue(imported) const result = await handlers.get('ssh:importConfig')!(null, {}) - expect(result).toEqual(imported) + expect(result).toEqual({ targets: imported, repoReadoptions: [] }) }) it('ssh:connect throws for unknown targetId', async () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 8b5756bff38..affb1aba004 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -11,6 +11,7 @@ import type { DetectedPort, EnrichedDetectedPort, SavedPortForward, + SshRepoReadoption, SshTarget, SshConnectionStatus, SshConnectionState @@ -744,15 +745,17 @@ export function registerSshHandlers( // Why: SSH target add/import can re-adopt workspaces orphaned on a removed // target id (see ssh-target-readoption). When that re-points repos, the // renderer must refresh its repo list to surface the reattached workspaces. - function notifyReposChangedIfReadopted(): void { - if (!sshStore || sshStore.lastReadoptedRepoCount <= 0) { - return + function takeRepoReadoptions(): SshRepoReadoption[] { + if (!sshStore || sshStore.lastRepoReadoptions.length === 0) { + return [] } - sshStore.lastReadoptedRepoCount = 0 + const repoReadoptions = sshStore.lastRepoReadoptions + sshStore.lastRepoReadoptions = [] const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('repos:changed') } + return repoReadoptions } ipcMain.handle('ssh:listTargets', () => { @@ -768,8 +771,8 @@ export function registerSshHandlers( // Why: re-adding a removed host can re-adopt orphaned workspaces (re-point // repos/worktrees off the dead id). Refresh the renderer's repo list so the // reattached workspaces move from grey ghosts back onto the live host. - notifyReposChangedIfReadopted() - return target + const repoReadoptions = takeRepoReadoptions() + return { target, repoReadoptions } }) ipcMain.handle( @@ -784,9 +787,9 @@ export function registerSshHandlers( }) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { - const result = sshStore!.importFromSshConfig(args) - notifyReposChangedIfReadopted() - return result + const targets = sshStore!.importFromSshConfig(args) + const repoReadoptions = takeRepoReadoptions() + return { targets, repoReadoptions } }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a0d3f760779..4e921151e4f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3317,9 +3317,9 @@ describe('Store', () => { store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') expect(repo.executionHostId).toBe('ssh:ssh-new') @@ -3338,9 +3338,9 @@ describe('Store', () => { }) ) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['ssh-repo']) expect(store.getRepo('local-repo')!.connectionId).toBeUndefined() expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new') }) @@ -3350,9 +3350,9 @@ describe('Store', () => { // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(1) + expect(repoIds).toEqual(['r1']) const repo = store.getRepo('r1')! expect(repo.connectionId).toBe('ssh-new') // Must not stamp an executionHostId where there wasn't one. @@ -3365,8 +3365,8 @@ describe('Store', () => { // must still be saved, not left in memory only. store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) - const count = store.reassignSshTargetId('ssh-old', 'ssh-new') - expect(count).toBe(0) // no repo matched + const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') + expect(repoIds).toEqual([]) // no repo matched store.flush() const reloaded = await createStore() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d4b68e7f4b4..0cac89b3f17 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6141,16 +6141,16 @@ export class Store { /** * Re-point every repo and worktree meta pinned to a removed SSH target id * onto a re-added target's id, so orphaned workspaces reattach to the live - * host instead of remaining un-removable ghosts. Returns the number of repos - * re-pointed (0 when nothing referenced the old id). + * host instead of remaining un-removable ghosts. Returns the ids of repos + * re-pointed (empty when nothing referenced the old id). */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): number { + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { if (oldTargetId === newTargetId) { - return 0 + return [] } const oldHostId = toSshExecutionHostId(oldTargetId) const newHostId = toSshExecutionHostId(newTargetId) - let repoCount = 0 + const repoIds = new Set() for (const repo of this.state.repos) { const matchesConnection = repo.connectionId === oldTargetId const matchesHost = repo.executionHostId === oldHostId @@ -6167,7 +6167,7 @@ export class Store { if (matchesHost) { repo.executionHostId = newHostId } - repoCount++ + repoIds.add(repo.id) } // Re-point worktree metas whose hostId pointed at the old SSH host. let metaChanged = false @@ -6238,13 +6238,13 @@ export class Store { // Why: repo-row and host-setup rewrites can affect host-setup compatibility, // but meta-only rewrites cannot — keep that sync under this gate. Persist // whenever anything changed, so partial re-points aren't lost on quit. - if (repoCount > 0 || setupsChanged) { + if (repoIds.size > 0 || setupsChanged) { this.syncProjectHostSetupCompatibilityState() } - if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) { + if (repoIds.size > 0 || metaChanged || carrierChanged || setupsChanged) { this.scheduleSave() } - return repoCount + return [...repoIds] } // ── SSH Remote PTY Leases ────────────────────────────────────────── diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 02e69c6dc97..4bfa7259dab 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -64,7 +64,7 @@ function createMockStore() { reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { reassignments.push({ oldTargetId, newTargetId }) // Pretend one repo referenced the old id. - return 1 + return ['repo-1'] }) } } @@ -458,6 +458,39 @@ describe('SshConnectionStore', () => { ) expect(result).toHaveLength(1) }) + + it('reports every exact repo migration from a multi-host re-import', () => { + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old-a', + configHost: 'host-a', + host: 'host-a.example.com', + port: 22, + username: '', + label: 'host-a', + removedAt: 1 + }) + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old-b', + configHost: 'host-b', + host: 'host-b.example.com', + port: 22, + username: '', + label: 'host-b', + removedAt: 1 + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'host-a' }, { host: 'host-b' }]) + sshConfigHostsToTargetsMock.mockReturnValue([ + candidate({ configHost: 'host-a' }), + candidate({ configHost: 'host-b' }) + ]) + + sshStore.importFromSshConfig({ reAdopt: true }) + + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: 'ssh-old-a', newTargetId: 'tmp-host-a', repoIds: ['repo-1'] }, + { oldTargetId: 'ssh-old-b', newTargetId: 'tmp-host-b', repoIds: ['repo-1'] } + ]) + }) }) describe('re-adoption of orphaned workspaces', () => { @@ -520,8 +553,9 @@ describe('SshConnectionStore', () => { const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0] expect(oldId).toBe('ssh-old') expect(newId).toMatch(/^ssh-/) - // Re-adoption count surfaces so the IPC layer can refresh the repo list. - expect(sshStore.lastReadoptedRepoCount).toBe(1) + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: newId, repoIds: ['repo-1'] } + ]) }) // Why: drive the real remove→re-add path so the tombstone carries the @@ -549,7 +583,9 @@ describe('SshConnectionStore', () => { }) expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id) - expect(sshStore.lastReadoptedRepoCount).toBe(1) + expect(sshStore.lastRepoReadoptions).toEqual([ + { oldTargetId: added.id, newTargetId: readded.id, repoIds: ['repo-1'] } + ]) }) // A different account on the SAME host must NOT re-adopt, even though both @@ -592,7 +628,7 @@ describe('SshConnectionStore', () => { }) expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() - expect(sshStore.lastReadoptedRepoCount).toBe(0) + expect(sshStore.lastRepoReadoptions).toEqual([]) }) }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index df3e6cd8fee..295e7d57ac3 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -1,5 +1,5 @@ import type { Store } from '../persistence' -import type { SshTarget } from '../../shared/ssh-types' +import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' import { @@ -44,14 +44,13 @@ export class SshConnectionStore { this.store.addSshTarget(full) // Why: re-adopt workspaces that were orphaned when the same host was removed // (repos/worktrees still point at the old, now-dead target id). Track the - // count so the IPC layer knows whether to refresh the repo list. - this.lastReadoptedRepoCount = readoptOrphanedWorkspacesForTarget(this.store, full) + // exact migrations so IPC can refresh and renderer can prune only proven stale rows. + this.lastRepoReadoptions = readoptOrphanedWorkspacesForTarget(this.store, full) return full } - /** Repos re-adopted by the most recent addTarget/importFromSshConfig call. - * Lets the IPC layer broadcast repos:changed only when workspaces reattached. */ - lastReadoptedRepoCount = 0 + /** Exact migrations from the most recent add/import operation. */ + lastRepoReadoptions: SshRepoReadoption[] = [] upsertRuntimeOwnedTarget( runtimeId: string, @@ -120,7 +119,7 @@ export class SshConnectionStore { * manual targets. Returns the inserted and updated targets. */ importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { - let readoptedThisImport = 0 + const readoptions: SshRepoReadoption[] = [] // Why: the explicit Import action re-adopts every config host, so it clears // all tombstones first. The passive on-open sync passes no flag and keeps // deleted hosts suppressed. @@ -212,12 +211,12 @@ export class SshConnectionStore { // Why: a freshly-inserted config host may be one the user removed and is // now re-importing — re-adopt its orphaned workspaces. Updated-in-place // targets keep their id, so their repos were never orphaned. - readoptedThisImport += readoptOrphanedWorkspacesForTarget(this.store, inserted) + readoptions.push(...readoptOrphanedWorkspacesForTarget(this.store, inserted)) changed.push(inserted) } } - this.lastReadoptedRepoCount = readoptedThisImport + this.lastRepoReadoptions = readoptions return changed } } diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index f263fe01d88..2b066a0bdc4 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -28,7 +28,7 @@ function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) { }, reassignSshTargetId: (oldTargetId: string, newTargetId: string) => { reassigned.push({ oldId: oldTargetId, newId: newTargetId }) - return 1 + return ['repo-1'] } } as unknown as Store return { store, reassigned, remaining: () => current } @@ -49,40 +49,44 @@ const tombstone = ( describe('readoptOrphanedWorkspacesForTarget', () => { it('re-adopts on matching configHost alias', () => { const fake = makeFakeStore([tombstone({ configHost: 'devbox', host: 'changed.example.com' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'devbox', host: 'now.example.com' }) ) - expect(count).toBe(1) + expect(readoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ]) expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) expect(fake.remaining()).toHaveLength(0) // tombstone consumed }) it('re-adopts on matching host+user+port when no alias', () => { const fake = makeFakeStore([tombstone()]) - const count = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) - expect(count).toBe(1) + const readoptions = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) + expect(readoptions).toEqual([ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ]) expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) }) it('does not re-adopt when identity differs', () => { const fake = makeFakeStore([tombstone({ host: 'other.example.com', username: 'root' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) expect(fake.remaining()).toHaveLength(1) // tombstone left for a future match }) it('matches host/user/port case-insensitively', () => { const fake = makeFakeStore([tombstone({ host: 'Dev.Example.COM', username: 'Tim' })]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) it('does not match alias against a different host tuple', () => { @@ -90,16 +94,16 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) }) it('is a no-op when there are no tombstones', () => { const fake = makeFakeStore([]) - expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toBe(0) + expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toEqual([]) }) it('does NOT re-adopt across two different aliases that share host+user+port', () => { @@ -108,11 +112,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'prod-deploy', host: 'prod.example.com', username: 'deploy' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'prod-admin', host: 'prod.example.com', username: 'deploy' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) expect(fake.remaining()).toHaveLength(1) // tombstone preserved }) @@ -129,7 +133,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'dev.example.com', @@ -138,7 +142,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'bob' }) ) - expect(count).toBe(0) + expect(readoptions).toEqual([]) expect(fake.reassigned).toEqual([]) }) @@ -151,7 +155,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: 'dev.example.com', @@ -160,7 +164,7 @@ describe('readoptOrphanedWorkspacesForTarget', () => { username: 'alice' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) it('still re-adopts via tuple when the re-added target has no alias', () => { @@ -169,11 +173,11 @@ describe('readoptOrphanedWorkspacesForTarget', () => { const fake = makeFakeStore([ tombstone({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) ]) - const count = readoptOrphanedWorkspacesForTarget( + const readoptions = readoptOrphanedWorkspacesForTarget( fake.store, makeTarget({ configHost: undefined, host: 'dev.example.com', username: 'tim' }) ) - expect(count).toBe(1) + expect(readoptions).toHaveLength(1) }) }) diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts index 06b0cefd8ec..321bbdb5c0a 100644 --- a/src/main/ssh/ssh-target-readoption.ts +++ b/src/main/ssh/ssh-target-readoption.ts @@ -1,5 +1,9 @@ import type { Store } from '../persistence' -import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' +import type { + RemovedSshTargetTombstone, + SshRepoReadoption, + SshTarget +} from '../../shared/ssh-types' /** * Re-adoption of workspaces orphaned when an SSH target was removed. @@ -56,14 +60,18 @@ function tombstoneMatches(tombstone: RemovedSshTargetTombstone, target: Identity /** * Re-point orphaned repos/worktrees onto `newTarget` if a removed target with * the same host identity is tombstoned. Consumes the matching tombstone(s). - * Returns the number of repos re-adopted (0 when there was nothing to adopt). + * Returns exact repo/target migrations so the renderer can discard only rows + * proven to be stale after its per-host catalog merge. */ -export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshTarget): number { +export function readoptOrphanedWorkspacesForTarget( + store: Store, + newTarget: SshTarget +): SshRepoReadoption[] { const tombstones = store.getRemovedSshTargetTombstones() if (tombstones.length === 0) { - return 0 + return [] } - let readopted = 0 + const readoptions: SshRepoReadoption[] = [] for (const tombstone of tombstones) { // Why: a re-added target can't share the id of one that still exists, but // guard anyway so we never re-point a live target onto itself. @@ -74,12 +82,15 @@ export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshT if (!tombstoneMatches(tombstone, newTarget)) { continue } - readopted += store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + const repoIds = store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + if (repoIds.length > 0) { + readoptions.push({ oldTargetId: tombstone.oldTargetId, newTargetId: newTarget.id, repoIds }) + } // Consume the tombstone whether or not it re-pointed anything: the host has // returned, so the record has served its purpose. store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) } - return readopted + return readoptions } /** Build a tombstone from a target about to be removed. */ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1bad72bb019..4babcd587bb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -383,6 +383,8 @@ import type { import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, SshTarget, PortForwardEntry, EnrichedDetectedPort @@ -3001,13 +3003,13 @@ export type PreloadApi = { // Removed-target id → last known label, for showing a friendly host name on // workspaces still pinned to a target that no longer exists. listRemovedTargetLabels: () => Promise> - addTarget: (args: { target: Omit }) => Promise + addTarget: (args: { target: Omit }) => Promise updateTarget: (args: { id: string updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: (args?: { reAdopt?: boolean }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 6f8692649b4..3c7af55f0af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -130,6 +130,8 @@ import { } from '../shared/rich-markdown-context-menu' import type { SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, SshTarget, PortForwardEntry, EnrichedDetectedPort @@ -4036,7 +4038,7 @@ const api = { listRemovedTargetLabels: (): Promise> => ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - addTarget: (args: { target: Omit }): Promise => + addTarget: (args: { target: Omit }): Promise => ipcRenderer.invoke('ssh:addTarget', args), updateTarget: (args: { @@ -4047,7 +4049,7 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (args?: { reAdopt?: boolean }): Promise => + importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 534383be3b5..2e66ca3823a 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -19,9 +19,7 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' export { getSshPaneSearchEntries } from './ssh-search' -type SshPaneProps = Record - -export function SshPane(_props: SshPaneProps): React.JSX.Element { +export function SshPane(): React.JSX.Element { const [targets, setTargets] = useState([]) // Why: connection states are already hydrated and kept up-to-date by the // global store (via useIpcEvents.ts). Reading from the store avoids @@ -72,7 +70,8 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { // a sync failure must not block listing the already-known targets. void (async () => { try { - await window.api.ssh.importConfig() + const result = await window.api.ssh.importConfig() + useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions) } catch { // Surfaced on demand via the explicit Import button; ignore here. } @@ -92,9 +91,12 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { } try { - await (editingId - ? window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates }) - : window.api.ssh.addTarget({ target: savePayload.payload.target })) + if (editingId) { + await window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates }) + } else { + const result = await window.api.ssh.addTarget({ target: savePayload.payload.target }) + useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions) + } recordFeatureInteraction('ssh') if (!mountedRef.current) { return @@ -288,17 +290,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { // Why: the explicit Import action re-adopts every ~/.ssh/config host, // including ones the user previously deleted — clear tombstones so a // deliberate re-import can bring them back. - const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[] + const result = await window.api.ssh.importConfig({ reAdopt: true }) + useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions) recordFeatureInteraction('ssh') if (mountedRef.current) { - if (synced.length === 0) { + if (result.targets.length === 0) { toast('~/.ssh/config already in sync') } else { toast.success( translate( 'auto.components.settings.SshPane.f8050f6307', 'Synced {{value0}} server{{value1}}', - { value0: synced.length, value1: synced.length > 1 ? 's' : '' } + { value0: result.targets.length, value1: result.targets.length > 1 ? 's' : '' } ) ) } diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index c6b7870381f..705d5537073 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -39,6 +39,7 @@ export function AddRemoteHostDialog({ const [isSaving, setIsSaving] = useState(false) const [isImporting, setIsImporting] = useState(false) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) + const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) @@ -106,7 +107,8 @@ export function AddRemoteHostDialog({ setIsSaving(true) try { - await window.api.ssh.addTarget({ target }) + const result = await window.api.ssh.addTarget({ target }) + recordSshRepoReadoptions(result.repoReadoptions) await refreshSshTargetMetadata() recordFeatureInteraction('ssh') toast.success( @@ -131,7 +133,9 @@ export function AddRemoteHostDialog({ const importSshConfig = async () => { setIsImporting(true) try { - const synced = (await window.api.ssh.importConfig()) as SshTarget[] + const result = await window.api.ssh.importConfig() + const synced = result.targets + recordSshRepoReadoptions(result.repoReadoptions) await refreshSshTargetMetadata() recordFeatureInteraction('ssh') if (synced.length === 0) { diff --git a/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts new file mode 100644 index 00000000000..8213af797f1 --- /dev/null +++ b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { reconcileReadoptedSshWorktreesByRepo } from './readopted-ssh-worktree-rows' + +const readoption = { + oldTargetId: 'ssh-old', + newTargetId: 'ssh-new', + repoIds: ['repo-1'] +} + +describe('reconcileReadoptedSshWorktreesByRepo', () => { + it('moves an old-host row onto the exact re-adopted host', () => { + const result = reconcileReadoptedSshWorktreesByRepo( + { 'repo-1': [{ id: 'worktree-1', hostId: 'ssh:ssh-old' }] }, + [readoption] + ) + + expect(result['repo-1']).toEqual([{ id: 'worktree-1', hostId: 'ssh:ssh-new' }]) + }) + + it('keeps the already-fetched new-host row when both hosts are present', () => { + const result = reconcileReadoptedSshWorktreesByRepo( + { + 'repo-1': [ + { id: 'worktree-1', hostId: 'ssh:ssh-old', label: 'stale' }, + { id: 'worktree-1', hostId: 'ssh:ssh-new', label: 'authoritative' } + ] + }, + [readoption] + ) + + expect(result['repo-1']).toEqual([ + { id: 'worktree-1', hostId: 'ssh:ssh-new', label: 'authoritative' } + ]) + }) + + it('leaves unrelated repo and host rows untouched', () => { + const rows: Record = { + 'repo-1': [{ id: 'other-host', hostId: 'ssh:ssh-other' }], + 'repo-2': [{ id: 'old-host', hostId: 'ssh:ssh-old' }] + } + + expect(reconcileReadoptedSshWorktreesByRepo(rows, [readoption])).toBe(rows) + }) +}) diff --git a/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts new file mode 100644 index 00000000000..0e4b1748669 --- /dev/null +++ b/src/renderer/src/store/slices/readopted-ssh-worktree-rows.ts @@ -0,0 +1,52 @@ +import type { SshRepoReadoption } from '../../../../shared/ssh-types' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' + +type HostedWorktree = { + id: string + hostId?: ExecutionHostId +} + +function reconcileRows( + rows: readonly T[], + oldHostId: ExecutionHostId, + newHostId: ExecutionHostId +): T[] { + const owners = new Set( + rows.filter((row) => row.hostId !== oldHostId).map((row) => `${row.id}\0${row.hostId ?? ''}`) + ) + const result: T[] = [] + for (const row of rows) { + if (row.hostId !== oldHostId) { + result.push(row) + continue + } + const key = `${row.id}\0${newHostId}` + if (!owners.has(key)) { + result.push({ ...row, hostId: newHostId }) + owners.add(key) + } + } + return result +} + +export function reconcileReadoptedSshWorktreesByRepo( + rowsByRepo: Readonly>, + readoptions: readonly SshRepoReadoption[] +): Record { + let result = rowsByRepo as Record + for (const readoption of readoptions) { + const oldHostId = toSshExecutionHostId(readoption.oldTargetId) + const newHostId = toSshExecutionHostId(readoption.newTargetId) + for (const repoId of readoption.repoIds) { + const rows = result[repoId] + if (!rows?.some((row) => row.hostId === oldHostId)) { + continue + } + if (result === rowsByRepo) { + result = { ...result } + } + result[repoId] = reconcileRows(rows, oldHostId, newHostId) + } + } + return result +} diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts new file mode 100644 index 00000000000..a6859b21bb3 --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1 +} + +const runtimeEnvironmentCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { + repos: { list: vi.fn().mockResolvedValue([localRepo]) }, + projects: { + list: vi.fn().mockResolvedValue([]), + listHostSetups: vi.fn().mockResolvedValue([]) + }, + runtimeEnvironments: { + list: vi.fn().mockResolvedValue([{ id: 'env-1', name: 'Remote' }]), + call: (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + } + }, + dispatchEvent: vi.fn() + }) +}) + +describe('fetchReposForAllHosts generation', () => { + it('does not validate repo UI from a superseded refresh', async () => { + let resolveOlderRemote!: (value: unknown) => void + let resolveNewerRemote!: (value: unknown) => void + let markOlderRemoteStarted!: () => void + let markNewerRemoteStarted!: () => void + const olderRemote = new Promise((resolve) => { + resolveOlderRemote = resolve + }) + const newerRemote = new Promise((resolve) => { + resolveNewerRemote = resolve + }) + const olderRemoteStarted = new Promise((resolve) => { + markOlderRemoteStarted = resolve + }) + const newerRemoteStarted = new Promise((resolve) => { + markNewerRemoteStarted = resolve + }) + let repoListCalls = 0 + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + repoListCalls++ + if (repoListCalls === 1) { + markOlderRemoteStarted() + return olderRemote + } + markNewerRemoteStarted() + return newerRemote + }) + const store = createTestStore() + store.setState({ + activeRepoId: 'remote-repo', + filterRepoIds: ['remote-repo'], + trustedOrcaHooks: { 'remote-repo': { all: { approvedAt: 1 } } } + }) + const response = { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + + const olderFetch = store.getState().fetchReposForAllHosts() + await olderRemoteStarted + const newerFetch = store.getState().fetchReposForAllHosts() + await newerRemoteStarted + resolveOlderRemote(response) + await olderFetch + + expect(store.getState().activeRepoId).toBe('remote-repo') + expect(store.getState().filterRepoIds).toEqual(['remote-repo']) + expect(store.getState().trustedOrcaHooks).toEqual({ + 'remote-repo': { all: { approvedAt: 1 } } + }) + + resolveNewerRemote(response) + await newerFetch + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) + }) +}) diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index c54d0a7edb6..6a96f557ade 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -286,7 +286,87 @@ function expectSharedProjectMetadata(projects: readonly Project[], sharedProject expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) } +function directSshRepo(targetId: string): Repo { + return { + ...localRepo, + id: 're-adopted-repo', + connectionId: targetId, + executionHostId: `ssh:${targetId}` + } +} + +const repoReadoption = { + oldTargetId: 'ssh-old', + newTargetId: 'ssh-new', + repoIds: ['re-adopted-repo'] +} + describe('fetchReposForAllHosts', () => { + it('prunes a superseded direct SSH row during a local catalog transaction', async () => { + const staleRepo = directSshRepo('ssh-old') + const liveRepo = directSshRepo('ssh-new') + reposList.mockResolvedValue([liveRepo]) + const store = createTestStore() + store.setState({ + repos: [staleRepo], + pendingSshRepoReadoptions: [repoReadoption] + }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().repos).toEqual([liveRepo]) + }) + + it('preserves an old direct SSH row when no re-adoption evidence exists', async () => { + const staleRepo = directSshRepo('ssh-old') + const liveRepo = directSshRepo('ssh-new') + reposList.mockResolvedValue([liveRepo]) + const store = createTestStore() + store.setState({ repos: [staleRepo] }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().repos).toEqual([staleRepo, liveRepo]) + }) + + it('reconciles a targeted runtime response against the latest repo state', async () => { + const staleRepo = directSshRepo('ssh-old') + const liveRepo = directSshRepo('ssh-new') + let resolveRepoList!: (response: unknown) => void + const repoListResponse = new Promise((resolve) => (resolveRepoList = resolve)) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return repoListResponse + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ + repos: [staleRepo], + pendingSshRepoReadoptions: [repoReadoption] + }) + + const load = store.getState().fetchRuntimeEnvironmentRepos('env-1') + store.setState({ repos: [staleRepo, liveRepo] }) + resolveRepoList({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await load + + expect(store.getState().repos).toEqual([ + liveRepo, + { ...remoteRepo, executionHostId: 'runtime:env-1' } + ]) + }) + it('loads local + all configured runtime environments even when a remote env is active', async () => { // Why: a cold start that restored a remote workspace leaves the remote // environment active. The active-host-only fetchRepos would drop local diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts new file mode 100644 index 00000000000..f53f3342072 --- /dev/null +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import { createTestStore } from './store-test-helpers' + +const repoId = 're-adopted-repo' +const projectId = `repo:${repoId}` + +function directSshRepo(targetId: string): Repo { + return { + id: repoId, + path: '/srv/repo', + displayName: 'Re-adopted repo', + badgeColor: '#000', + addedAt: 1, + connectionId: targetId, + executionHostId: `ssh:${targetId}` + } +} + +function directSshSetup(targetId: string): ProjectHostSetup { + return { + id: `setup-${targetId}`, + projectId, + hostId: `ssh:${targetId}`, + repoId, + path: '/srv/repo', + displayName: 'Re-adopted repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + connectionId: targetId, + executionHostId: `ssh:${targetId}` + } +} + +function directSshWorktree(targetId: string, displayName = 'main'): Worktree { + return { + id: `${repoId}::/srv/repo`, + repoId, + hostId: `ssh:${targetId}`, + path: '/srv/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } +} + +const project: Project = { + id: projectId, + displayName: 'Re-adopted repo', + badgeColor: '#000', + sourceRepoIds: [repoId], + createdAt: 1, + updatedAt: 1 +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const setupsList = vi.fn() +const worktreesListDetected = vi.fn() + +beforeEach(() => { + reposList.mockReset() + projectsList.mockReset() + setupsList.mockReset() + worktreesListDetected.mockReset() + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups: setupsList }, + worktrees: { listDetected: worktreesListDetected } + }, + dispatchEvent: vi.fn() + }) +}) + +describe('SSH repo host reconciliation', () => { + const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repoId] } + + it('reconciles when repos:changed finishes before add-target evidence arrives', async () => { + const staleRepo = directSshRepo('ssh-old') + const liveRepo = directSshRepo('ssh-new') + reposList.mockResolvedValue([liveRepo]) + projectsList.mockResolvedValue([project]) + setupsList.mockResolvedValue([directSshSetup('ssh-new')]) + const store = createTestStore() + store.setState({ + repos: [staleRepo], + projectHostSetups: [directSshSetup('ssh-old')], + worktreesByRepo: { + [repoId]: [ + directSshWorktree('ssh-old', 'stale'), + directSshWorktree('ssh-new', 'authoritative') + ] + } + }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + expect(store.getState().repos).toEqual([staleRepo, liveRepo]) + + store.getState().recordSshRepoReadoptions([readoption]) + + expect(store.getState().repos).toEqual([liveRepo]) + expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().worktreesByRepo[repoId]).toEqual([ + directSshWorktree('ssh-new', 'authoritative') + ]) + }) + + it('keeps evidence pending until repos:changed delivers the new-host row', async () => { + const liveRepo = directSshRepo('ssh-new') + const runtimeSetup: ProjectHostSetup = { + ...directSshSetup('ssh-runtime'), + id: 'runtime-setup', + hostId: 'runtime:env-1', + executionHostId: 'runtime:env-1' + } + reposList.mockResolvedValue([liveRepo]) + projectsList.mockResolvedValue([project]) + setupsList.mockResolvedValue([directSshSetup('ssh-new')]) + const store = createTestStore() + store.setState({ + repos: [directSshRepo('ssh-old')], + projectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] + }) + + store.getState().recordSshRepoReadoptions([readoption]) + expect(store.getState().repos).toEqual([directSshRepo('ssh-old')]) + expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().repos).toEqual([liveRepo]) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([directSshSetup('ssh-new'), runtimeSetup]) + ) + expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().pendingSshRepoReadoptions).toEqual([]) + }) + + it('drops an older all-host catalog that resolves after evidence is consumed', async () => { + const staleRepo = directSshRepo('ssh-old') + const liveRepo = directSshRepo('ssh-new') + let resolveOldSetups!: (setups: ProjectHostSetup[]) => void + let markOldSetupStarted!: () => void + const oldSetups = new Promise((resolve) => { + resolveOldSetups = resolve + }) + const oldSetupStarted = new Promise((resolve) => { + markOldSetupStarted = resolve + }) + reposList.mockResolvedValueOnce([staleRepo]).mockResolvedValueOnce([liveRepo]) + projectsList.mockResolvedValue([project]) + setupsList + .mockImplementationOnce(() => { + markOldSetupStarted() + return oldSetups + }) + .mockResolvedValueOnce([directSshSetup('ssh-new')]) + const store = createTestStore() + store.setState({ repos: [staleRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + store.getState().recordSshRepoReadoptions([readoption]) + + const olderFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await oldSetupStarted + const newerFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await newerFetch + resolveOldSetups([directSshSetup('ssh-old')]) + await olderFetch + + expect(store.getState().repos).toEqual([liveRepo]) + expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + }) + + it('rejects an old-host worktree response that resolves after re-adoption', async () => { + const staleWorktree = directSshWorktree('ssh-old', 'stale') + let resolveOldWorktrees!: (value: unknown) => void + const oldWorktrees = new Promise((resolve) => { + resolveOldWorktrees = resolve + }) + worktreesListDetected.mockReturnValueOnce(oldWorktrees) + reposList.mockResolvedValue([directSshRepo('ssh-new')]) + projectsList.mockResolvedValue([project]) + setupsList.mockResolvedValue([directSshSetup('ssh-new')]) + const store = createTestStore() + store.setState({ + repos: [directSshRepo('ssh-old')], + worktreesByRepo: { [repoId]: [staleWorktree] }, + detectedWorktreesByRepo: { + [repoId]: { + repoId, + authoritative: true, + source: 'git', + worktrees: [ + { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } + }) + + const staleFetch = store.getState().fetchWorktrees(repoId) + await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) + store.getState().recordSshRepoReadoptions([readoption]) + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + resolveOldWorktrees({ + repoId, + authoritative: true, + source: 'git', + worktrees: [ + { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + }) + await staleFetch + + expect(store.getState().worktreesByRepo[repoId]).toEqual([ + directSshWorktree('ssh-new', 'stale') + ]) + expect(store.getState().detectedWorktreesByRepo[repoId].worktrees).toEqual([ + { + ...directSshWorktree('ssh-new', 'stale'), + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + } + ]) + }) + + it('rejects a worktree response after its final repo owner is removed', async () => { + const staleWorktree = directSshWorktree('ssh-old', 'stale') + let resolveOldWorktrees!: (value: unknown) => void + const oldWorktrees = new Promise((resolve) => { + resolveOldWorktrees = resolve + }) + worktreesListDetected.mockReturnValueOnce(oldWorktrees) + const store = createTestStore() + store.setState({ repos: [directSshRepo('ssh-old')] }) + + const staleFetch = store.getState().fetchWorktrees(repoId) + await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) + store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} }) + resolveOldWorktrees({ + repoId, + authoritative: true, + source: 'git', + worktrees: [ + { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + }) + await staleFetch + + expect(store.getState().worktreesByRepo[repoId]).toBeUndefined() + expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined() + }) + + it('preserves a forgettable SSH ghost when a local repo shares its UUID', async () => { + const localRepo: Repo = { + ...directSshRepo('ssh-old'), + path: '/local/repo', + connectionId: undefined, + executionHostId: undefined + } + const oldRepo = directSshRepo('ssh-old') + reposList.mockResolvedValue([localRepo, oldRepo]) + projectsList.mockResolvedValue([project]) + setupsList.mockResolvedValue([directSshSetup('ssh-old')]) + const store = createTestStore() + store.setState({ repos: [localRepo, oldRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }, oldRepo]) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([directSshSetup('ssh-old')]) + ) + expect(store.getState().projectHostSetups).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6fc31768e28..4538b91212e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -5,6 +5,7 @@ auditing and preserving. */ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' +import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { GlobalSettings, Project, @@ -48,7 +49,12 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' -import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows' +import { + mergeSshRepoReadoptions, + reconcileReadoptedSshRepoRows, + type SshRepoReconciliation +} from './superseded-ssh-repo-rows' +import { reconcileReadoptedSshWorktreesByRepo } from './readopted-ssh-worktree-rows' import { splitRepoReorderByHost } from './repo-reorder-host-split' import { omitSparsePresetsForRepos } from './sparse-presets' import { @@ -700,8 +706,19 @@ function mergeFetchedProjectCompatibilityForHost({ repos: readonly Repo[] hostId: string }): Pick { - const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) - const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) + const setupBelongsToFetchedCatalog = (setup: ProjectHostSetup): boolean => { + if (hostId !== LOCAL_EXECUTION_HOST_ID) { + return setup.hostId === hostId + } + const owner = parseExecutionHostId(setup.hostId) + // Why: desktop persistence owns local and direct-SSH setups; runtime setups + // remain authoritative on their remote Orca server. + return setup.hostId === LOCAL_EXECUTION_HOST_ID || owner?.kind === 'ssh' + } + const fetchedSetupsForHost = fetched.projectHostSetups.filter(setupBelongsToFetchedCatalog) + const preservedSetups = previous.projectHostSetups.filter( + (setup) => !setupBelongsToFetchedCatalog(setup) + ) const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) const reposById = getReposById(repos) @@ -923,15 +940,78 @@ function mergeFetchedRepoCatalog( currentRepos: readonly Repo[] ): { repos: Repo[] - projectCompatibility: Pick + projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } { const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId) - const projectCompatibility = mergeProjectHostSetupCompatibility( - projectCompatibilityFromRepos(repos), - catalog.projectHostSetupCompatibility + return { + repos, + projectHostSetupCompatibility: catalog.projectHostSetupCompatibility, + hostId: catalog.hostId + } +} + +function reconcileSupersededSshRepos( + repos: readonly Repo[], + state: Pick +): SshRepoReconciliation { + return reconcileReadoptedSshRepoRows(repos, state.pendingSshRepoReadoptions) +} + +function filterSetupsForPrunedRepoRows( + setups: readonly ProjectHostSetup[], + mergedRepos: readonly Repo[], + reconciledRepos: readonly Repo[] +): ProjectHostSetup[] { + const survivingOwners = new Set( + reconciledRepos.map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) ) - return { repos, projectCompatibility, hostId: catalog.hostId } + const prunedOwners = new Set( + mergedRepos + .filter((repo) => !survivingOwners.has(`${getRepoExecutionHostId(repo)}:${repo.id}`)) + .map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) + ) + if (prunedOwners.size === 0) { + return [...setups] + } + return setups.filter( + (setup) => !setup.repoId || !prunedOwners.has(`${setup.hostId}:${setup.repoId}`) + ) +} + +function reconcileReadoptedSshWorktreeState( + state: Pick, + readoptions: readonly SshRepoReadoption[] +): Pick { + const worktreesByRepo = reconcileReadoptedSshWorktreesByRepo(state.worktreesByRepo, readoptions) + const detectedRows = Object.fromEntries( + Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + result.worktrees + ]) + ) + const reconciledDetectedRows = reconcileReadoptedSshWorktreesByRepo(detectedRows, readoptions) + const detectedWorktreesByRepo = + reconciledDetectedRows === detectedRows + ? state.detectedWorktreesByRepo + : Object.fromEntries( + Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + { ...result, worktrees: reconciledDetectedRows[repoId] } + ]) + ) + return { + worktreesByRepo, + detectedWorktreesByRepo, + sortEpoch: worktreesByRepo === state.worktreesByRepo ? state.sortEpoch : state.sortEpoch + 1 + } +} + +function projectCompatibilityForReconciledRepos( + repos: readonly Repo[], + fetched: ProjectHostSetupProjection +): Pick { + return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched) } function filterTrustedOrcaHooksToValidRepos( @@ -969,17 +1049,6 @@ function clearRestoredFolderWorkspaceSessionOwners( return next } -async function fetchReposForTarget( - target: ReturnType, - currentRepos: readonly Repo[] -): Promise<{ - repos: Repo[] - projectCompatibility: Pick - hostId: ReturnType -}> { - return mergeFetchedRepoCatalog(await fetchRepoCatalogForTarget(target), currentRepos) -} - async function fetchProjectGroupCatalogForTarget( target: ReturnType ): Promise { @@ -1319,6 +1388,8 @@ export type RepoSlice = { activeRepoId: string | null // Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020). reposFetchGeneration: number + pendingSshRepoReadoptions: SshRepoReadoption[] + recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void fetchRepos: () => Promise fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise @@ -1440,6 +1511,32 @@ export const createRepoSlice: StateCreator = (set, folderWorkspacePathStatuses: {}, activeRepoId: null, reposFetchGeneration: 0, + pendingSshRepoReadoptions: [], + + recordSshRepoReadoptions: (readoptions) => + set((s) => { + const pendingSshRepoReadoptions = mergeSshRepoReadoptions( + s.pendingSshRepoReadoptions, + readoptions + ) + const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) + const repos = reconciliation.repos + const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) + const projectHostSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) + const compatibility = mergeProjectHostSetupCompatibility( + projectCompatibilityFromRepos(repos), + { + projects: s.projects, + setups: projectHostSetups + } + ) + return { + repos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...worktreeState, + ...compatibility + } + }), fetchRepos: async () => { // Why: overlapping repos:changed fetches can resolve out of order; an earlier @@ -1451,36 +1548,45 @@ export const createRepoSlice: StateCreator = (set, }) try { const target = getActiveRuntimeTarget(get().settings) - const { - repos: reconciledRepos, - projectCompatibility, - hostId - } = await fetchReposForTarget(target, get().repos) + const catalog = await fetchRepoCatalogForTarget(target) // A newer fetchRepos superseded us while we awaited — drop this stale result. if (get().reposFetchGeneration !== generation) { return } + let finalizedHostRepos: Repo[] = [] set((s) => { // Why: after re-adoption re-points a repo onto a re-added SSH target, the // per-host merge leaves the stale row on the old (removed) target id — a // ghost a terminal pane can bind to and fail with "SSH target not found". // Drop rows on unknown SSH targets that a live-host sibling supersedes. - const prunedRepos = pruneSupersededSshRepoRows( - reconciledRepos, - new Set(s.sshTargetLabels.keys()) - ) + const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const prunedRepos = reconciliation.repos const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) + const projectCompatibility = projectCompatibilityForReconciledRepos( + prunedRepos, + catalog.projectHostSetupCompatibility + ) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ previous: { projects: s.projects, - projectHostSetups: s.projectHostSetups + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + prunedRepos + ) }, fetched: projectCompatibility, repos: prunedRepos, - hostId + hostId: result.hostId }) + finalizedHostRepos = prunedRepos.filter( + (repo) => getRepoExecutionHostId(repo) === result.hostId + ) return { repos: prunedRepos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, @@ -1491,10 +1597,7 @@ export const createRepoSlice: StateCreator = (set, ) } }) - scheduleSafeAutoForkSync( - get, - reconciledRepos.filter((repo) => getRepoExecutionHostId(repo) === hostId) - ) + scheduleSafeAutoForkSync(get, finalizedHostRepos) } catch (err) { console.error('Failed to fetch repos:', err) } @@ -1503,24 +1606,37 @@ export const createRepoSlice: StateCreator = (set, fetchRuntimeEnvironmentRepos: async (environmentId) => { try { const target = { kind: 'environment' as const, environmentId } - const { - repos: reconciledRepos, - projectCompatibility, - hostId - } = await fetchReposForTarget(target, get().repos) - const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) + const catalog = await fetchRepoCatalogForTarget(target) + let finalizedHostRepos: Repo[] = [] set((s) => { + const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const finalizedRepos = reconciliation.repos + const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) + const projectCompatibility = projectCompatibilityForReconciledRepos( + finalizedRepos, + catalog.projectHostSetupCompatibility + ) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ previous: { projects: s.projects, - projectHostSetups: s.projectHostSetups + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + finalizedRepos + ) }, fetched: projectCompatibility, - repos: reconciledRepos, - hostId + repos: finalizedRepos, + hostId: result.hostId }) + finalizedHostRepos = finalizedRepos.filter( + (repo) => getRepoExecutionHostId(repo) === result.hostId + ) return { - repos: reconciledRepos, + repos: finalizedRepos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), @@ -1530,11 +1646,8 @@ export const createRepoSlice: StateCreator = (set, ) } }) - const fetchedHostRepos = reconciledRepos.filter( - (repo) => getRepoExecutionHostId(repo) === hostId - ) - scheduleSafeAutoForkSync(get, fetchedHostRepos) - return fetchedHostRepos + scheduleSafeAutoForkSync(get, finalizedHostRepos) + return finalizedHostRepos } catch (err) { console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err) return [] @@ -1542,6 +1655,11 @@ export const createRepoSlice: StateCreator = (set, }, fetchReposForAllHosts: async (options) => { + let generation = 0 + set((s) => { + generation = s.reposFetchGeneration + 1 + return { reposFetchGeneration: generation } + }) // Why: a cold start that restores a remote workspace re-activates that // remote runtime environment, and fetching only the active host hides every // other host's repos (notably all local repos), which reads as "my projects @@ -1550,21 +1668,38 @@ export const createRepoSlice: StateCreator = (set, // environment is active. Each host fails soft: an unreachable/disconnected // host is skipped without blocking the others. const applyCatalog = (catalog: FetchedRepoCatalog): void => { + // Why: repos:changed can start another all-host refresh while this one is + // in flight. Never let the older catalog resurrect a migrated SSH owner. + if (get().reposFetchGeneration !== generation) { + return + } let hostRepos: Repo[] = [] set((s) => { const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const finalizedRepos = reconciliation.repos + const projectCompatibility = projectCompatibilityForReconciledRepos( + finalizedRepos, + catalog.projectHostSetupCompatibility + ) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ previous: { projects: s.projects, - projectHostSetups: s.projectHostSetups + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + finalizedRepos + ) }, - fetched: result.projectCompatibility, - repos: result.repos, + fetched: projectCompatibility, + repos: finalizedRepos, hostId: result.hostId }) - hostRepos = result.repos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) + hostRepos = finalizedRepos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) return { - repos: result.repos, + repos: finalizedRepos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId, @@ -1600,6 +1735,9 @@ export const createRepoSlice: StateCreator = (set, failed = true console.error('Failed to fetch local repos for all-host load:', err) } + if (get().reposFetchGeneration !== generation) { + return + } if (options?.remoteHosts === 'skip') { return } @@ -1625,7 +1763,7 @@ export const createRepoSlice: StateCreator = (set, // Why: first-paint startup intentionally loads only local repos before // remotes answer. Validate repo-scoped UI only once every configured host has // answered; otherwise an offline runtime would erase its saved filters. - if (!failed) { + if (!failed && get().reposFetchGeneration === generation) { validateRepoScopedUi() } }, diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts index 64299ac3704..ec18aea767d 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { pruneSupersededSshRepoRows } from './superseded-ssh-repo-rows' +import { describe, expect, it } from 'vitest' import type { Repo } from '../../../../shared/types' +import { mergeSshRepoReadoptions, reconcileReadoptedSshRepoRows } from './superseded-ssh-repo-rows' function repo(overrides: Partial): Repo { return { @@ -13,44 +13,71 @@ function repo(overrides: Partial): Repo { } } -describe('pruneSupersededSshRepoRows', () => { - it('drops a stale dead-SSH row when a live-host sibling shares the id', () => { - // The re-adoption leftover: same id on a dead target + a live one. - const repos = [ - repo({ id: 'shared', connectionId: 'ssh-dead' }), - repo({ id: 'shared', connectionId: 'ssh-live' }) - ] - const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) - expect(result.map((r) => r.connectionId)).toEqual(['ssh-live']) +const readoption = { + oldTargetId: 'ssh-old', + newTargetId: 'ssh-new', + repoIds: ['shared'] +} + +describe('reconcileReadoptedSshRepoRows', () => { + it('drops only the exact old-host row after the new-host row arrives', () => { + const local = repo({ id: 'shared', path: '/local' }) + const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' }) + const newSsh = repo({ id: 'shared', connectionId: 'ssh-new' }) + + const result = reconcileReadoptedSshRepoRows([local, oldSsh, newSsh], [readoption]) + + expect(result.repos).toEqual([local, newSsh]) + expect(result.pendingReadoptions).toEqual([]) }) - it('KEEPS a lone project-only ghost (no live sibling) so it can still be forgotten', () => { - const repos = [repo({ id: 'ghost', connectionId: 'ssh-dead' })] - const result = pruneSupersededSshRepoRows(repos, new Set()) - expect(result).toHaveLength(1) - expect(result[0].connectionId).toBe('ssh-dead') + it('keeps evidence pending when repos:changed has not delivered the new row yet', () => { + const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' }) + + const result = reconcileReadoptedSshRepoRows([oldSsh], [readoption]) + + expect(result.repos).toEqual([oldSsh]) + expect(result.pendingReadoptions).toEqual([readoption]) }) - it('drops a dead-SSH row superseded by a LOCAL sibling', () => { - // A repo id on both local and a removed SSH host: the local row is the live - // sibling, so the SSH ghost is a re-adoption/duplicate leftover → drop it. - const repos = [repo({ id: 'shared' }), repo({ id: 'shared', connectionId: 'ssh-dead' })] - const result = pruneSupersededSshRepoRows(repos, new Set()) - expect(result.map((r) => r.connectionId ?? 'local')).toEqual(['local']) + it('keeps a removed SSH ghost when a local repo shares its UUID without evidence', () => { + const repos = [repo({ id: 'shared' }), repo({ id: 'shared', connectionId: 'ssh-old' })] + + expect(reconcileReadoptedSshRepoRows(repos, []).repos).toEqual(repos) }) - it('leaves live SSH rows untouched', () => { - const repos = [repo({ id: 'a', connectionId: 'ssh-live' }), repo({ id: 'b' })] - const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) - expect(result).toHaveLength(2) + it('does not accept a local or runtime sibling as the mapped new SSH row', () => { + const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' }) + const local = repo({ id: 'shared' }) + const runtime = repo({ + id: 'shared', + connectionId: 'ssh-new', + executionHostId: 'runtime:env-1' + }) + + const result = reconcileReadoptedSshRepoRows([oldSsh, local, runtime], [readoption]) + + expect(result.repos).toEqual([oldSsh, local, runtime]) + expect(result.pendingReadoptions).toEqual([readoption]) }) - it('never prunes runtime-owned SSH rows', () => { - const repos = [ - repo({ id: 'shared', connectionId: 'runtime-ssh-abc' }), - repo({ id: 'shared', connectionId: 'ssh-live' }) - ] - const result = pruneSupersededSshRepoRows(repos, new Set(['ssh-live'])) - expect(result).toHaveLength(2) + it('leaves unrelated same-UUID SSH rows untouched', () => { + const other = repo({ id: 'shared', connectionId: 'ssh-other' }) + const newSsh = repo({ id: 'shared', connectionId: 'ssh-new' }) + + const result = reconcileReadoptedSshRepoRows([other, newSsh], [readoption]) + + expect(result.repos).toEqual([other, newSsh]) + }) +}) + +describe('mergeSshRepoReadoptions', () => { + it('combines repo ids for the same old-to-new migration', () => { + const result = mergeSshRepoReadoptions( + [{ ...readoption, repoIds: ['a'] }], + [{ ...readoption, repoIds: ['a', 'b'] }] + ) + + expect(result).toEqual([{ ...readoption, repoIds: ['a', 'b'] }]) }) }) diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts index c97d9bb96e0..da87da70291 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -1,50 +1,82 @@ +import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { Repo } from '../../../../shared/types' -import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' + +export type SshRepoReconciliation = { + repos: Repo[] + pendingReadoptions: SshRepoReadoption[] +} + +function repoBelongsToTarget(repo: Repo, targetId: string): boolean { + return ( + repo.connectionId === targetId && + getRepoExecutionHostId(repo) === toSshExecutionHostId(targetId) + ) +} + +function repoOwnerKey(hostId: string, repoId: string): string { + return `${hostId}\0${repoId}` +} /** - * Drops repo rows left stranded on a removed SSH target after re-adoption - * re-pointed the same repo onto a re-added host. - * - * Re-adoption (main) rewrites a repo's connectionId from the old (dead) target - * id to the new one, but the renderer's per-host repo merge preserves rows on - * "other hosts" — and the dead SSH host still looks like another host, so its - * stale row lingers. A terminal pane bound to that ghost row then fails with - * "SSH target not found". - * - * A row is superseded (and pruned) only when ALL hold: - * - it targets an SSH connection that is NOT a currently-known target, and - * - the same repo id also exists on a DIFFERENT host that IS known/live. - * - * This never removes a legitimate lone project-only ghost host (its repo id - * exists only on the dead host, so there is no live sibling to supersede it) — - * those are kept on purpose so the sidebar can still surface and forget them. + * Drops old-host rows only when main reports the exact repo re-adoption and the + * renderer has received the corresponding new-host row. Evidence stays pending + * across the add-response/repos:changed race until that row arrives. */ -export function pruneSupersededSshRepoRows( +export function reconcileReadoptedSshRepoRows( repos: readonly Repo[], - knownSshTargetIds: ReadonlySet -): Repo[] { - const isDeadSshRow = (repo: Repo): boolean => { - const connectionId = repo.connectionId?.trim() - if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { - return false - } - return !knownSshTargetIds.has(connectionId) - } + readoptions: readonly SshRepoReadoption[] +): SshRepoReconciliation { + const prunedOwners = new Set() + const pendingReadoptions: SshRepoReadoption[] = [] + const directSshOwners = new Set( + repos.flatMap((repo) => + repo.connectionId && repoBelongsToTarget(repo, repo.connectionId) + ? [repoOwnerKey(getRepoExecutionHostId(repo), repo.id)] + : [] + ) + ) - // Repo ids that have at least one row on a known/live host (local or a live - // SSH/runtime target). Only these can supersede a dead-host sibling. - const idsWithLiveHost = new Set() - for (const repo of repos) { - if (!isDeadSshRow(repo)) { - idsWithLiveHost.add(repo.id) + for (const readoption of readoptions) { + const pendingRepoIds: string[] = [] + for (const repoId of readoption.repoIds) { + const newOwner = repoOwnerKey(toSshExecutionHostId(readoption.newTargetId), repoId) + const hasNewRow = directSshOwners.has(newOwner) + if (!hasNewRow) { + pendingRepoIds.push(repoId) + continue + } + prunedOwners.add(repoOwnerKey(toSshExecutionHostId(readoption.oldTargetId), repoId)) + } + if (pendingRepoIds.length > 0) { + pendingReadoptions.push({ ...readoption, repoIds: pendingRepoIds }) } } - return repos.filter((repo) => { - if (!isDeadSshRow(repo)) { - return true - } - // Keep a lone ghost (no live sibling); drop only a superseded leftover. - return !idsWithLiveHost.has(repo.id) + if (prunedOwners.size === 0) { + return { repos: [...repos], pendingReadoptions } + } + return { + repos: repos.filter( + (repo) => !prunedOwners.has(repoOwnerKey(getRepoExecutionHostId(repo), repo.id)) + ), + pendingReadoptions + } +} + +export function mergeSshRepoReadoptions( + pending: readonly SshRepoReadoption[], + incoming: readonly SshRepoReadoption[] +): SshRepoReadoption[] { + const repoIdsByMigration = new Map>() + for (const readoption of [...pending, ...incoming]) { + const key = `${readoption.oldTargetId}\0${readoption.newTargetId}` + const repoIds = repoIdsByMigration.get(key) ?? new Set() + readoption.repoIds.forEach((repoId) => repoIds.add(repoId)) + repoIdsByMigration.set(key, repoIds) + } + return [...repoIdsByMigration].map(([key, repoIds]) => { + const [oldTargetId, newTargetId] = key.split('\0') + return { oldTargetId, newTargetId, repoIds: [...repoIds] } }) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index e419631e736..315e23aaf70 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -352,6 +352,21 @@ function repoHostId( return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } +function repoHasExecutionHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId, + ownerWasMissingAtStart: boolean +): boolean { + const repoOwners = state.repos.filter((repo) => repo.id === repoId) + // Why: worktrees can load before the repo catalog during startup; only reject + // a missing owner when this request previously observed an owned repo. + return ( + (repoOwners.length === 0 && ownerWasMissingAtStart) || + repoOwners.some((repo) => getRepoExecutionHostId(repo) === hostId) + ) +} + function toVisibleWorktrees( result: DetectedWorktreeListResult, hostId: ExecutionHostId, @@ -2253,6 +2268,7 @@ export const createWorktreeSlice: StateCreator try { const ownerState = get() const hostId = repoHostId(ownerState, repoId) + const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) const result = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), @@ -2260,6 +2276,9 @@ export const createWorktreeSlice: StateCreator { executionHostId: hostId } ) set((s) => { + if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + return s + } // Why: detected-only refreshes can overlap host-scoped visible refreshes; // keep detected state stamped/merged so SSH/runtime rows are not clobbered. const mergedDetected = mergeDetectedWorktreesForHost( @@ -2287,6 +2306,7 @@ export const createWorktreeSlice: StateCreator try { const ownerState = get() const hostId = repoHostId(ownerState, repoId) + const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) const settings = settingsForRepoOwner(ownerState, repoId, hostId) const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { @@ -2307,6 +2327,9 @@ export const createWorktreeSlice: StateCreator ) if (areWorktreesEqual(currentForHost, worktrees)) { set((s) => { + if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + return s + } const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( s, @@ -2360,22 +2383,30 @@ export const createWorktreeSlice: StateCreator // to-navigate silently fails because findWorktreeById returns undefined. // Keep the stale-but-correct data until the next successful refresh. if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { - set((s) => ({ - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, repoId, hostId) - ) + set((s) => { + if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + return s } - })) + return { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + setup, + worktreeHostMatchOptions(s, repoId, hostId) + ) + } + } + }) return false } set((s) => { + if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + return s + } // Why: hidden worktrees are not in worktreesByRepo. Purge decisions // must diff against the previous authoritative detected list so hiding // does not delete state, and deleting a hidden worktree still does. @@ -2437,6 +2468,9 @@ export const createWorktreeSlice: StateCreator get().worktreesByRepo[r.id] ) set((s) => { + if (!repoHasExecutionHost(s, r.id, hostId, false)) { + return s + } const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( s, @@ -2522,6 +2556,9 @@ export const createWorktreeSlice: StateCreator !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) ) { set((s) => { + if (!repoHasExecutionHost(s, r.id, hostId, false)) { + return s + } const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) return { worktreesByRepo: { @@ -2542,18 +2579,23 @@ export const createWorktreeSlice: StateCreator } }) } else { - set((s) => ({ - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [r.id]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, r.id, hostId) - ) + set((s) => { + if (!repoHasExecutionHost(s, r.id, hostId, false)) { + return s } - })) + return { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + setup, + worktreeHostMatchOptions(s, r.id, hostId) + ) + } + } + }) } return { repoId: r.id, ok: detected.authoritative, detected } } catch (err) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5e7c61a12ff..cd77c51acef 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2823,7 +2823,7 @@ function createSshApi(): NonNullable['ssh']> { updateTarget: () => Promise.reject(new Error('SSH target management is unavailable in the web client.')), removeTarget: () => Promise.resolve(), - importConfig: () => Promise.resolve([]), + importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), connect: async (args) => { const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( 'ssh.connect', diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 8627f485bb3..5794cb5226c 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -68,6 +68,23 @@ export type RemovedSshTargetTombstone = { removedAt: number } +/** Exact repo ownership changes made while re-adopting a removed SSH host. */ +export type SshRepoReadoption = { + oldTargetId: string + newTargetId: string + repoIds: string[] +} + +export type SshTargetAddResult = { + target: SshTarget + repoReadoptions: SshRepoReadoption[] +} + +export type SshConfigImportResult = { + targets: SshTarget[] + repoReadoptions: SshRepoReadoption[] +} + export type SavedPortForward = { localPort: number remoteHost: string diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts index 75fc21a47e5..87f1f120d30 100644 --- a/tests/e2e/ssh-codex-reconnect-replay-driver.ts +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -39,7 +39,7 @@ export async function connectDockerRemote( void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) }) try { - const createdTarget = await window.api.ssh.addTarget({ + const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ target: { label: `Docker SSH Codex Artifact Repro ${Date.now()}`, host: '127.0.0.1', @@ -50,6 +50,7 @@ export async function connectDockerRemote( relayGracePeriodSeconds: 1 } }) + store.getState().recordSshRepoReadoptions(repoReadoptions) const state = await window.api.ssh.connect({ targetId: createdTarget.id }) if (!state || state.status !== 'connected') { throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index f36f44e9445..13f90d4399b 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -103,7 +103,7 @@ async function connectDockerRemote( void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) }) try { - const createdTarget = await window.api.ssh.addTarget({ + const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ target: { label: `Docker SSH Relay Perf ${Date.now()}`, host: '127.0.0.1', @@ -114,6 +114,7 @@ async function connectDockerRemote( relayGracePeriodSeconds: 1 } }) + store.getState().recordSshRepoReadoptions(repoReadoptions) const state = await window.api.ssh.connect({ targetId: createdTarget.id }) if (!state || state.status !== 'connected') { throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index c406186d97c..1117fcb9409 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -170,7 +170,7 @@ test.describe('Localhost SSH', () => { }) try { - const createdTarget = await window.api.ssh.addTarget({ + const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ target: { ...target, // Why: local-only E2E should not leave a long-lived relay process @@ -178,6 +178,7 @@ test.describe('Localhost SSH', () => { relayGracePeriodSeconds: 1 } }) + store.getState().recordSshRepoReadoptions(repoReadoptions) let state try { diff --git a/tests/e2e/ssh-pi-compatible-agent-title.spec.ts b/tests/e2e/ssh-pi-compatible-agent-title.spec.ts index 02634022540..d8c28d0efd0 100644 --- a/tests/e2e/ssh-pi-compatible-agent-title.spec.ts +++ b/tests/e2e/ssh-pi-compatible-agent-title.spec.ts @@ -47,7 +47,7 @@ async function connectDockerRemote( void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) }) try { - const createdTarget = await window.api.ssh.addTarget({ + const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ target: { label: `Docker SSH Pi-Compatible Agent ${Date.now()}`, host: '127.0.0.1', @@ -58,6 +58,7 @@ async function connectDockerRemote( relayGracePeriodSeconds: 1 } }) + store.getState().recordSshRepoReadoptions(repoReadoptions) const state = await window.api.ssh.connect({ targetId: createdTarget.id }) if (!state || state.status !== 'connected') { throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) From c51d955f0cabf19fc308f7efc3197752cfde42bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:46:04 -0700 Subject: [PATCH 068/138] fix: hide completed Pi agent row after closing its terminal tab (#6584) * fix: sweep completed Pi agent row when its terminal tab is closed A 'done' Pi agent entry lingers in agentStatusByPaneKey after its terminal tab is closed: its paneKey's tabId no longer matches any open tab, so dropAgentStatusByTabPrefix never sweeps it. The sidebar selector getLiveEntriesByWorktree used an unconditional fallback (tabIdToWorktreeId.get(parsed.tabId) ?? entry.worktreeId), added in #4371 to surface early child-agent rows before their tab reaches the renderer. That fallback re-attributed the orphaned 'done' entry to the worktree, so buildWorktreeAgentRows rendered it as a stale live 'Done' row forever (Codex/Claude tear down cleanly because their pane/tab keying matches the closed tab). Restrict the worktree-attribution fallback to non-'done' entries so completed rows whose tab is gone are dropped, while still surfacing active child rows whose tab has not yet reached the renderer. Supersedes #5914 (adopted and re-verified). Fixes #5913 Co-authored-by: Alberto Cuervo * fix: drop completed Pi orphan during tab close * fix: skip retention suppressor for completed-orphan keys during tab close A completed-orphan key's tab is already gone, so buildRetainedAgentsSyncSnapshot never snapshots it and no live->gone transition ever fires to consume a planted retention suppressor. Planting one leaked a permanent retentionSuppressedPaneKeys entry per swept orphan. Plant suppressors only for prefix-matched keys. Co-authored-by: Orca --------- Co-authored-by: Alberto Cuervo Co-authored-by: Orca --- .../worktree-agent-row-selectors.test.ts | 21 ++++++ .../sidebar/worktree-agent-row-selectors.ts | 4 +- .../store/slices/agent-status-drop.test.ts | 68 ++++++++++++++++++- src/renderer/src/store/slices/agent-status.ts | 65 +++++++++++++++--- src/renderer/src/store/slices/terminals.ts | 17 ++++- 5 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts index c3d33e7f08e..a40b6840038 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts @@ -150,6 +150,27 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([childEntry]) }) + + it('does not use worktree attribution for a completed row whose tab is gone', () => { + const closedEntry = makeEntry(PANE_KEY_1, 1000, { + state: 'done', + worktreeId: 'wt-1', + tabId: 'tab-1', + agentType: 'pi' + }) + const state = { + tabsByWorktree: { + 'wt-1': [makeTab('tab-live')] + }, + agentStatusByPaneKey: { + [PANE_KEY_1]: closedEntry + }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {} + } + + expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([]) + }) }) describe('selectRuntimeAgentOrchestrationForWorktree', () => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 8fe9290ba9f..567454f45cc 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -97,7 +97,9 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map { expect(s.sortEpoch).toBe(sortEpochBefore + 1) }) + it('closeTab drops completed worktree-attributed orphan rows', () => { + vi.useFakeTimers() + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-closed', worktreeId: 'wt-1' }), + makeTab({ id: 'tab-live', worktreeId: 'wt-1' }) + ], + 'wt-2': [] + } + }) + store + .getState() + .setAgentStatus('tab-closed:0', { state: 'done', prompt: 'closed', agentType: 'pi' }) + store + .getState() + .setAgentStatus( + 'tab-orphan:0', + { state: 'done', prompt: 'orphan', agentType: 'pi' }, + undefined, + undefined, + { worktreeId: 'wt-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-active-child:0', + { state: 'working', prompt: 'active child', agentType: 'pi' }, + undefined, + undefined, + { worktreeId: 'wt-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-live:0', + { state: 'done', prompt: 'open tab', agentType: 'pi' }, + undefined, + undefined, + { worktreeId: 'wt-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-other-orphan:0', + { state: 'done', prompt: 'other worktree', agentType: 'pi' }, + undefined, + undefined, + { worktreeId: 'wt-2' } + ) + + store.getState().closeTab('tab-closed') + + const s = store.getState() + expect(s.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-closed')).toBe(false) + expect(s.agentStatusByPaneKey['tab-closed:0']).toBeUndefined() + expect(s.agentStatusByPaneKey['tab-orphan:0']).toBeUndefined() + // No suppressor for the orphan: its tab is already gone, so retention sync + // never re-surfaces it and a suppressor would leak permanently. + expect(s.retentionSuppressedPaneKeys['tab-orphan:0']).toBeUndefined() + expect(s.agentStatusByPaneKey['tab-active-child:0']).toBeDefined() + expect(s.agentStatusByPaneKey['tab-live:0']).toBeDefined() + expect(s.agentStatusByPaneKey['tab-other-orphan:0']).toBeDefined() + }) + it('on a paneKey with neither live nor retained entry: no-op (same state reference, no epoch bumps)', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 05f0c2039c7..99ae087f5b9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -64,6 +64,10 @@ type DropHibernatedAgentPaneOptions = { retainedCompletionEvidence?: readonly RetainedAgentEntry[] } +type DropAgentStatusByTabPrefixOptions = { + worktreeId?: string +} + type AgentLaunchConfigRegistrationMetadata = { agentType?: AgentType launchToken?: string @@ -175,7 +179,10 @@ export type AgentStatusSlice = { /** Remove all entries under a tab AND suppress re-retention for each. * Used on tab close — the user is tearing down the whole tab, so any * remaining agent rows (live or retained) must not reappear. */ - dropAgentStatusByTabPrefix: (tabIdPrefix: string) => void + dropAgentStatusByTabPrefix: ( + tabIdPrefix: string, + opts?: DropAgentStatusByTabPrefixOptions + ) => void /** Remove one automatically hibernated completed-agent pane while preserving * sibling live/retained rows in the same worktree. */ @@ -307,6 +314,29 @@ function getLeafIdFromPaneKey(paneKey: string): string | null { return leafId.length > 0 ? leafId : null } +function findCompletedOrphanPaneKeysForTabClose( + state: AppState, + worktreeId: string | undefined, + prefix: string +): string[] { + if (!worktreeId) { + return [] + } + const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const paneKeys: string[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { + continue + } + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId || openTabIds.has(tabId)) { + continue + } + paneKeys.push(paneKey) + } + return paneKeys +} + function isRecentlyClosedAgentStatusTab( closedTabs: Record, tabId: string | null @@ -1810,16 +1840,25 @@ export const createAgentStatusSlice: StateCreator { + dropAgentStatusByTabPrefix: (tabIdPrefix, opts) => { const prefix = `${tabIdPrefix}:` let hadLive = false set((s) => { - const liveKeys = Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)) - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter((k) => - k.startsWith(prefix) + const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose( + s, + opts?.worktreeId, + prefix ) - const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter((k) => - k.startsWith(prefix) + const completedOrphanKeySet = new Set(completedOrphanKeys) + const liveKeys = [ + ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), + ...completedOrphanKeys + ] + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) ) const migrationUnsupported = pruneMigrationUnsupportedEntries( s.migrationUnsupportedByPtyId, @@ -1829,7 +1868,9 @@ export const createAgentStatusSlice: StateCreator k.startsWith(prefix)) + const ackKeys = Object.keys(nextAck).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) if (ackKeys.length > 0) { nextAck = { ...nextAck } for (const k of ackKeys) { @@ -1890,7 +1931,13 @@ export const createAgentStatusSlice: StateCreator !(k in s.retentionSuppressedPaneKeys)) + // + // Skip completed-orphan keys: their tab is already gone, so retention + // sync never snapshots them and no live→gone transition ever fires to + // consume the suppressor — planting one would leak permanently. + const suppressorAdds = liveKeys.filter( + (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) + ) let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys if (suppressorAdds.length > 0) { nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 514f5b01e7c..a674e3a1138 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1188,13 +1188,18 @@ export const createTerminalSlice: StateCreator }, closeTab: (tabId, opts) => { + let closingWorktreeId: string | null = null set((s) => { const next = { ...s.tabsByWorktree } let closingPtyId: string | null = null for (const wId of Object.keys(next)) { const before = next[wId] - if (!closingPtyId) { - closingPtyId = before.find((t) => t.id === tabId)?.ptyId ?? null + const closingTab = before.find((t) => t.id === tabId) + if (closingTab) { + closingWorktreeId = wId + if (!closingPtyId) { + closingPtyId = closingTab.ptyId ?? null + } } const after = before.filter((t) => t.id !== tabId) if (after.length !== before.length) { @@ -1345,7 +1350,13 @@ export const createTerminalSlice: StateCreator // too. Use dropAgentStatusByTabPrefix (not removeAgentStatusByTabPrefix) // so retention suppressors are planted: a live→gone transition inside the // same frame as the tab close cannot re-snapshot a row we just dropped. - get().dropAgentStatusByTabPrefix(tabId) + // Why: Pi can leave a completed row attributed to the worktree but keyed + // under an already-missing tab id; pass the worktree to sweep only that + // completed orphan while preserving active pre-render child rows. + get().dropAgentStatusByTabPrefix( + tabId, + closingWorktreeId ? { worktreeId: closingWorktreeId } : undefined + ) // Why: retired pane keys never recur, so stranded foreground entries would // accumulate for the renderer's whole lifetime. get().clearPaneForegroundAgentByTabPrefix(tabId) From a4583eb76506616f378fe333b3cb2ecd1532109f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:59:58 -0700 Subject: [PATCH 069/138] fix(rate-limits): auto-refresh stuck on "Refresh failed" after update relaunch (#5355) (#6560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rate-limits): debounce auto-refresh off last successful fetch A failed post-update/relaunch fetch stamped the single debounce timestamp the same as a success, so refreshIfWindowActive() no-oped every focus/show/restore and the deferred-startup retry for the next 5 minutes — leaving the status bar stuck on "Refresh failed" until the 15-minute background poll (which on Windows also requires the relaunched window to be focused). Key the debounce off the last SUCCESSFUL read instead: only stamp lastSuccessfulFetchAt when a provider returned fresh 'ok' data, so a failed fetch no longer suppresses auto-recovery on the next window activation. Removes the now-unused lastFetchAt field. Fixes #5355 Co-authored-by: Orca * fix(rate-limits): narrow active refresh retries * fix(rate-limits): refresh after live Claude credential handoff * fix(rate-limits): cover antigravity provider in active refresh plan Co-authored-by: Orca * refactor(rate-limits): compile-enforce active-refresh provider coverage Co-authored-by: Orca * fix(rate-limits): keep durable non-Claude/Codex error retries on the 5-minute full-fetch cadence Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/rate-limits/service.test.ts | 207 ++++++++++++++++++++++++++- src/main/rate-limits/service.ts | 140 +++++++++++++++--- 2 files changed, 325 insertions(+), 22 deletions(-) diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 166442ee0ae..261a94f36be 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -29,14 +29,14 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() -})) - vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + vi.mock('./minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -73,7 +73,7 @@ async function flushMicrotasks(times = 4): Promise { } function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -93,7 +93,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], message: string ): ProviderRateLimits { return { @@ -106,6 +106,20 @@ function errorProvider( } } +function unavailableProvider( + provider: ProviderRateLimits['provider'], + message = 'Not configured' +): ProviderRateLimits { + return { + provider, + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'unavailable' + } +} + function serviceInternals(service: RateLimitService): { fetchAll: () => Promise } { return service as unknown as { fetchAll: () => Promise } } @@ -344,6 +358,187 @@ describe('RateLimitService', () => { } }) + it('recovers a failed deferred-startup Claude fetch on the next focus', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'auth restarting')) + .mockResolvedValueOnce(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(service.getState().claude?.status).toBe('error') + expect(service.getState().codex?.status).toBe('ok') + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('throttles repeated active-window retries while Claude is still failing', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a full-fetch retry on the 5-minute cadence for a provider without a dedicated fetch cycle', async () => { + vi.useFakeTimers() + try { + // Kimi has no individual fetch cycle, so recovering it re-runs fetchAll + // (which hits Claude's tight-budget endpoint). A durable Kimi error must + // not drive that full fetch every 30s — it stays on the 5-minute cadence. + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(errorProvider('kimi', 'token expired')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().kimi?.status).toBe('error') + + // First activation recovers immediately (retry timestamps start at 0). + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Well past the 30s failure throttle but inside the 5-minute window: the + // full fetch (and the Claude read it entails) must not fire again. + await vi.advanceTimersByTimeAsync(2 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // After the full 5-minute window the retry fires again. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('debounces unavailable providers on active window events', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('still debounces a focus event within the window after a successful fetch', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // A focus within MIN_REFETCH_MS after a GOOD fetch must still no-op. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index b3800b31807..cd301cfe516 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -59,6 +59,15 @@ type MiniMaxResolvedConfig = { } type GeminiCliOAuthEnabledResolver = () => boolean +type ActiveRateLimitProvider = ProviderRateLimits['provider'] +type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it @@ -67,6 +76,15 @@ const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: these providers have a dedicated fetch cycle, so an activation retry can +// refresh just the failing one. Providers without one force a full fetchAll, so +// their error retries stay on the 5-minute cadence to protect Claude's budget. +const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 @@ -122,7 +140,18 @@ export class RateLimitService { private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null - private lastFetchAt = 0 + // Why: after the first recovery attempt, repeated focus/show/restore events + // during the same outage should not create a tight provider retry loop. + private lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } private mainWindow: BrowserWindow | null = null private detachWindowListeners: (() => void) | null = null private isFetching = false @@ -698,17 +727,104 @@ export class RateLimitService { return this.mainWindow.isFocused() } + private getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a newly added provider is compile-forced to have + // an active-refresh entry — a missing one silently never recovers from a + // startup error (antigravity was omitted once and needed a fix-up). + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data. Keep it eligible for + // activation recovery while throttling repeated events per provider. + if (limits.status === 'error') { + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? ACTIVE_FAILURE_REFETCH_MS + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + await this.fetchAll() + return + } + + // Why: a fetch already in flight will refresh these providers; skip without + // consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: partial failures of providers with a dedicated fetch cycle should + // recover without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + private async refreshIfWindowActive(): Promise { if (!this.shouldBackgroundPoll()) { return } - // Why: startup intentionally skips the pre-paint fetch. The first visible - // activation must still populate usage after update relaunches where the - // timer can be focus-gated for a long time. - if (Date.now() - this.lastFetchAt < MIN_REFETCH_MS) { - return - } - await this.fetchAll() + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) } private async fetchAll(options?: { force?: boolean }): Promise { @@ -1411,8 +1527,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { @@ -1465,8 +1579,6 @@ export class RateLimitService { ...this.state, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex }) - - this.lastFetchAt = Date.now() } private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { @@ -1524,8 +1636,6 @@ export class RateLimitService { ? this.applyStalePolicy(claude, previousState.claude) : this.state.claude }) - - this.lastFetchAt = Date.now() } private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { @@ -1563,8 +1673,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private applyStalePolicy( From ff8192a33108860a297615e64d46ec26924b78e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:36:47 +0000 Subject: [PATCH 070/138] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 2e0180ff127..065c613358b 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.5m + + downloads: 4.6m @@ -15,7 +15,7 @@ downloads downloads - 4.5m - 4.5m + 4.6m + 4.6m From 9b3ed3997e9a6f4d90735f55500d134d0b36c0f4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:45:55 -0700 Subject: [PATCH 071/138] fix(daemon): resolve Unix shell fallback before node-pty spawn (#8306) * fix: resolve daemon shell before spawning * fix(daemon): reconcile shell-ready barrier with the resolved fallback shell The adapter computes shellReadySupported from the preferred shell before spawn. When the daemon falls back (e.g. to /bin/sh), the session would queue startup commands for the full 15s ready-marker timeout and wrap multiline prompts in bracketed-paste markers the fallback shell cannot parse. Expose the spawned shell on SubprocessHandle and downgrade the barrier and paste wrapping in TerminalHost when the actual shell cannot emit the marker. Also apply the Unix shell fallback after the relay env scrub so a scrubbed SHELL cannot drop the corrected value, and cover the resolve- before-launch-config ordering, the no-shell throw, and candidate dedup with tests. Co-authored-by: Orca --------- Co-authored-by: Vitus Apollo Co-authored-by: Orca --- src/main/daemon/pty-subprocess.test.ts | 116 +++++++++++++++++++++ src/main/daemon/pty-subprocess.ts | 13 +++ src/main/daemon/session.ts | 4 + src/main/daemon/shell-ready.ts | 9 +- src/main/daemon/terminal-host.test.ts | 85 ++++++++++++++- src/main/daemon/terminal-host.ts | 15 ++- src/main/providers/local-pty-utils.test.ts | 48 ++++++++- src/main/providers/local-pty-utils.ts | 25 ++++- 8 files changed, 306 insertions(+), 9 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ca21dd5e58b..99a35047743 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -9,10 +9,12 @@ const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock, + resolveUnixShellPathMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), resolveAgentForegroundProcessMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { @@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) @@ -116,6 +119,8 @@ describe('createPtySubprocess', () => { async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) validateWorkingDirectoryMock.mockClear() + resolveUnixShellPathMock.mockReset() + resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath) isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] @@ -181,6 +186,117 @@ describe('createPtySubprocess', () => { ) }) + it('resolves a missing Unix default before spawning node-pty', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + + expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh') + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-l'], + expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) }) + ) + expect(warn).toHaveBeenCalledWith( + '[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"' + ) + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + } + }) + + it('derives shell-ready launch config from the resolved fallback shell', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const previousMarker = process.env.ORCA_SHELL_READY_MARKER + const previousZdotdir = process.env.ZDOTDIR + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + // Why: the test runner itself can execute inside an Orca-wrapped shell + // whose exported wrapper vars would leak through the process.env spread. + delete process.env.ORCA_SHELL_READY_MARKER + delete process.env.ZDOTDIR + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: {}, + command: 'echo hi' + }) + + expect(handle.shellPath).toBe('/bin/sh') + const [shellPath, shellArgs, spawnOptions] = spawnMock.mock.calls[0] + expect(shellPath).toBe('/bin/sh') + expect(shellArgs).toEqual(['-l']) + // A launch config derived from the missing preferred zsh would inject + // ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them. + expect(spawnOptions.env.ZDOTDIR).toBeUndefined() + expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(spawnOptions.env.SHELL).toBe('/bin/sh') + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + if (previousMarker === undefined) { + delete process.env.ORCA_SHELL_READY_MARKER + } else { + process.env.ORCA_SHELL_READY_MARKER = previousMarker + } + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + } + }) + + it('surfaces the no-executable-shell error before node-pty forks', () => { + resolveUnixShellPathMock.mockImplementation(() => { + throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)') + }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })).toThrow( + 'No executable Unix shell found' + ) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6a285301406..ce1b1faf35c 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, + resolveUnixShellPath, validateWorkingDirectory } from '../providers/local-pty-utils' import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' @@ -742,6 +743,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (opts.env?.TERM) { env.TERM = opts.env.TERM } + // Why after the scrub: SHELL must reflect the shell that actually spawns, + // matching LocalPtyProvider; and before the launch-config derivation below + // so shell-ready wrappers target the resolved shell, not a missing one. + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } // Why: OpenCode/Codex path restoration and OMP's typed-command status // wrapper need shell-ready code after user startup files run. let shellLaunch: ReturnType | null = null @@ -1007,6 +1019,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + shellPath, ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 06404fd9246..b9c82a48174 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -48,6 +48,10 @@ export type SubprocessHandle = { /** True when shell launch args already delivered the startup command, so the * terminal host must skip its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean + /** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The + * host reconciles the caller's shell-ready assumption against it so a + * fallback shell without a ready marker never gates startup commands. */ + shellPath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 909a75ddf1f..d602f5f7fa5 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -348,13 +348,16 @@ export function resolvePtyShellPath(env: Record): string { return env.SHELL || process.env.SHELL || '/bin/zsh' } +export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean { + const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() + return shellName === 'zsh' || shellName === 'bash' +} + export function supportsPtyStartupBarrier(env: Record): boolean { if (process.platform === 'win32') { return false } - const resolvedShell = resolvePtyShellPath(env) - const shellName = pathWin32.basename(basename(resolvedShell)).toLowerCase() - return shellName === 'zsh' || shellName === 'bash' + return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) } type ShellLaunchConfig = { diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f92d4c1e1f..46e495e5f2e 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,7 +3,7 @@ import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' function createMockSubprocess( - options: { startupCommandDeliveredInShellArgs?: boolean } = {} + options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {} ): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null @@ -12,6 +12,7 @@ function createMockSubprocess( ...(options.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), + ...(options.shellPath ? { shellPath: options.shellPath } : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -193,6 +194,88 @@ describe('TerminalHost', () => { } }) + it('delivers startup commands immediately when the spawned shell cannot emit the ready marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) + }) + + it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'claude "line one\nline two"', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] + expect(written).not.toContain('\x1b[200~') + expect(written).toContain('line one\nline two') + }) + + it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/bash' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) + it('does not write startup commands already embedded in shell args', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 203719d354e..b15420fed14 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,5 +1,6 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' @@ -104,6 +105,16 @@ export class TerminalHost { terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) + // Why: the caller computed shellReadySupported from the preferred shell, + // before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready + // marker, so keeping the stale flag would queue startup commands until the + // shell-ready timeout and bracketed-paste-wrap them for a line editor + // without paste mode. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || + shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ sessionId: opts.sessionId, cols: size.cols, @@ -111,7 +122,7 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, - shellReadySupported: opts.shellReadySupported ?? false, + shellReadySupported, historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's @@ -143,7 +154,7 @@ export class TerminalHost { session.write( buildStartupCommandSubmission(opts.command, { submit, - bracketedPasteSafe: opts.shellReadySupported ?? false + bracketedPasteSafe: shellReadySupported }) ) } diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 66fde349ded..0527922f4cc 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock })) -import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { + resolveUnixShellPath, + spawnShellWithFallback, + validateWorkingDirectory +} from './local-pty-utils' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' const NATIVE_DIR = 'C:\\Users\\jin\\repo' @@ -102,6 +106,48 @@ describe('validateWorkingDirectory', () => { }) }) +describe('resolveUnixShellPath', () => { + beforeEach(() => { + existsSyncMock.mockReset() + accessSyncMock.mockReset() + }) + + it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { + expect(resolveUnixShellPath('bash')).toBe('bash') + expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('selects an executable fallback before node-pty can fork a missing shell', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ + '/missing/zsh', + '/bin/zsh', + '/bin/bash', + '/bin/sh' + ]) + }) + + it('reports every attempted shell when no executable candidate exists', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( + 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' + ) + }) + + it('dedupes a preferred shell that is already a fallback candidate', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh']) + }) +}) + describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 53d016d312f..3e21095d535 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,4 +1,4 @@ -import { basename, join } from 'node:path' +import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' @@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' let didEnsureSpawnHelperExecutable = false +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + function toUnpackedAsarPath(candidate: string): string { return candidate .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') @@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null { return null } +/** + * Resolves an absolute Unix shell before node-pty forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} + /** * Ensure the node-pty spawn-helper binary has the executable bit set. * @@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu // Try fallback shells on Unix if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) for (const fallback of fallbackShells) { if (getShellValidationError(fallback)) { continue From 9202f92220d93113eaf8902ce52e42bf62960f75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:57:58 +0000 Subject: [PATCH 072/138] release: v1.4.136-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f5400d484e..5cb55814377 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.136-rc.0", + "version": "1.4.136-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From e538a93129b04f587e8b49cd71c8bb25e3a757c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:31:32 -0700 Subject: [PATCH 073/138] Cap Kimi session index cache (#7673) --- .../session-scanner-kimi-index-cache.test.ts | 164 ++++++++++++++++++ .../session-scanner-kimi-index-cache.ts | 132 ++++++++++++++ .../session-scanner-kimi-parser.test.ts | 37 +++- .../ai-vault/session-scanner-kimi-paths.ts | 48 +++-- 4 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-kimi-index-cache.test.ts create mode 100644 src/main/ai-vault/session-scanner-kimi-index-cache.ts diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts new file mode 100644 index 00000000000..549c1a3c170 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS, + type KimiSessionIndexIdentity +} from './session-scanner-kimi-index-cache' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' + +const IDENTITY: KimiSessionIndexIdentity = { + changeTimeMs: 1, + mtimeMs: 1, + sizeBytes: 1 +} +let tempDirs: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + clearKimiSessionIndexCache() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('KimiSessionIndexCache', () => { + it('bounds prolonged path churn and retains a reused index', async () => { + const cache = new KimiSessionIndexCache() + + for (let index = 0; index < KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + const reusedPath = 'home-0/session_index.jsonl' + await cache.get(reusedPath, IDENTITY, cache.beginRead(), async () => new Map()) + + const firstOverflowPath = `home-${KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS}/session_index.jsonl` + await cache.get( + firstOverflowPath, + IDENTITY, + cache.beginRead(), + async () => new Map([[firstOverflowPath, firstOverflowPath]]) + ) + expect(cache.has(reusedPath)).toBe(true) + expect(cache.has('home-1/session_index.jsonl')).toBe(false) + + for (let index = KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS + 1; index < 640; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + + expect(cache.has(reusedPath)).toBe(false) + expect(cache.size).toBe(KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) + expect(cache.has('home-576/session_index.jsonl')).toBe(true) + expect(cache.has('home-575/session_index.jsonl')).toBe(false) + cache.clear() + }) + + it('refreshes active entries and expires them after an idle TTL', async () => { + vi.useFakeTimers() + const cache = new KimiSessionIndexCache() + const path = 'active/session_index.jsonl' + const value = new Map([['session', '/repo']]) + + await cache.get(path, IDENTITY, cache.beginRead(), async () => value) + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map())).toBe(value) + + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(cache.has(path)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + expect(cache.has(path)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates concurrent reads of the same file identity', async () => { + const cache = new KimiSessionIndexCache() + const load = vi.fn(async () => new Map([['session', '/repo']])) + const first = cache.get('index', IDENTITY, cache.beginRead(), load) + const second = cache.get('index', IDENTITY, cache.beginRead(), load) + + expect(second).toBe(first) + await expect(second).resolves.toEqual(new Map([['session', '/repo']])) + expect(load).toHaveBeenCalledOnce() + cache.clear() + }) + + it('does not let an older mutation race replace a newer identity', async () => { + const cache = new KimiSessionIndexCache() + const oldGeneration = cache.beginRead() + const newGeneration = cache.beginRead() + const newerIdentity = { ...IDENTITY, changeTimeMs: 2, mtimeMs: 2, sizeBytes: 2 } + const newer = new Map([['session', '/new']]) + + await cache.get('index', newerIdentity, newGeneration, async () => newer) + await cache.get('index', IDENTITY, oldGeneration, async () => new Map([['session', '/old']])) + const reload = vi.fn(async () => new Map()) + + await expect(cache.get('index', newerIdentity, cache.beginRead(), reload)).resolves.toBe(newer) + expect(reload).not.toHaveBeenCalled() + cache.clear() + }) + + it('does not repopulate after an owner clears an in-flight read', async () => { + const cache = new KimiSessionIndexCache() + const staleGeneration = cache.beginRead() + cache.clear() + + await cache.get('index', IDENTITY, staleGeneration, async () => new Map([['session', '/old']])) + + expect(cache.has('index')).toBe(false) + const current = new Map([['session', '/current']]) + await expect( + cache.get('index', IDENTITY, cache.beginRead(), async () => current) + ).resolves.toBe(current) + expect(cache.has('index')).toBe(true) + cache.clear() + }) +}) + +describe('Kimi session index reader cache', () => { + it('releases a retained map when its index file disappears', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session', workDir: '/repo' })}\n`) + + await readKimiWorkDirBySessionId(indexPath) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(true) + await rm(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(false) + }) + + it('invalidates when size changes even if the mtime is restored', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-mutation-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n`) + const originalStat = await stat(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([['session-old', '/old']]) + ) + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n${JSON.stringify({ sessionId: 'session-new', workDir: '/new' })}\n` + ) + await utimes(indexPath, originalStat.atime, originalStat.mtime) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([ + ['session-old', '/old'], + ['session-new', '/new'] + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts new file mode 100644 index 00000000000..13ebe3c1426 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -0,0 +1,132 @@ +export type KimiSessionIndexIdentity = { + changeTimeMs: number + mtimeMs: number + sizeBytes: number +} + +type KimiSessionIndexCacheEntry = { + expiresAt: number + generation: number + identity: KimiSessionIndexIdentity + timer: NodeJS.Timeout | null + value: Promise> +} + +export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 +// Active Vault scans refresh this window; closing the surface releases parsed +// index maps soon without making a live Kimi session reread on every scan. +export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 + +export class KimiSessionIndexCache { + private readonly entries = new Map() + private minimumCacheGeneration = 0 + private nextGeneration = 0 + + beginRead(): number { + this.nextGeneration += 1 + return this.nextGeneration + } + + clear(): void { + for (const entry of this.entries.values()) { + if (entry.timer) { + clearTimeout(entry.timer) + } + } + this.entries.clear() + // Why: a read already awaiting stat/load when its owner clears the cache + // may finish later, but must not silently recreate the released entry. + this.minimumCacheGeneration = this.nextGeneration + 1 + } + + delete(indexPath: string, generation = Number.POSITIVE_INFINITY): void { + const entry = this.entries.get(indexPath) + if (entry && entry.generation <= generation) { + this.forget(indexPath, entry) + } + } + + get( + indexPath: string, + identity: KimiSessionIndexIdentity, + generation: number, + load: () => Promise> + ): Promise> { + if (generation < this.minimumCacheGeneration) { + return load() + } + const cached = this.entries.get(indexPath) + const now = Date.now() + if (cached && cached.expiresAt > now && identitiesMatch(cached.identity, identity)) { + this.remember(indexPath, cached, now) + return cached.value + } + if (cached && cached.generation > generation) { + // Why: a slower, older stat must not replace a newer file generation + // that another concurrent scan already cached for the same path. + return load() + } + + const entry: KimiSessionIndexCacheEntry = { + expiresAt: now + KIMI_WORK_DIR_CACHE_TTL_MS, + generation, + identity, + timer: null, + value: load() + } + this.remember(indexPath, entry, now) + return entry.value + } + + has(indexPath: string): boolean { + return this.entries.has(indexPath) + } + + get size(): number { + return this.entries.size + } + + private forget(indexPath: string, entry: KimiSessionIndexCacheEntry): void { + if (this.entries.get(indexPath) !== entry) { + return + } + if (entry.timer) { + clearTimeout(entry.timer) + } + this.entries.delete(indexPath) + } + + private remember(indexPath: string, entry: KimiSessionIndexCacheEntry, now: number): void { + const replaced = this.entries.get(indexPath) + if (replaced?.timer && replaced !== entry) { + clearTimeout(replaced.timer) + } + if (entry.timer) { + clearTimeout(entry.timer) + } + entry.expiresAt = now + KIMI_WORK_DIR_CACHE_TTL_MS + entry.timer = setTimeout(() => this.forget(indexPath, entry), KIMI_WORK_DIR_CACHE_TTL_MS) + entry.timer.unref() + this.entries.delete(indexPath) + this.entries.set(indexPath, entry) + + while (this.entries.size > KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) { + const oldest = this.entries.entries().next().value + if (!oldest) { + return + } + this.forget(oldest[0], oldest[1]) + } + } +} + +function identitiesMatch( + left: KimiSessionIndexIdentity, + right: KimiSessionIndexIdentity +): boolean { + return ( + left.changeTimeMs === right.changeTimeMs && + left.mtimeMs === right.mtimeMs && + left.sizeBytes === right.sizeBytes + ) +} diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index bb268fbdcb1..67748ae727f 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' import type { FileWithMtime } from './session-scanner-types' let tempDirs: string[] = [] @@ -218,4 +223,34 @@ describe('parseKimiSessionFile', () => { const session = await parseKimiSessionFile(file, 'darwin') expect(session?.title).toBe('do the thing') }) + + it('caps cached session index maps by recent index path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-cache-')) + tempDirs.push(root) + const indexPaths: string[] = [] + + for (let index = 0; index <= KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const home = join(root, `home-${index}`) + await mkdir(home, { recursive: true }) + const indexPath = join(home, 'session_index.jsonl') + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: `session_${index}`, workDir: `/tmp/kimi-${index}` })}\n` + ) + indexPaths.push(indexPath) + } + + for (const indexPath of indexPaths.slice(0, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)) { + await readKimiWorkDirBySessionId(indexPath) + } + const refreshedFirst = await readKimiWorkDirBySessionId(indexPaths[0]) + expect(refreshedFirst.get('session_0')).toBe('/tmp/kimi-0') + await readKimiWorkDirBySessionId(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[0])).toBe(true) + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[1])).toBe(false) + expect( + hasKimiSessionIndexCacheEntryForTests(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + ).toBe(true) + }) }) diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index db307cb22d0..7ed457f6c07 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -4,6 +4,13 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import { asRecord, extractString } from './session-scanner-values' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS +} from './session-scanner-kimi-index-cache' + +export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -55,38 +62,41 @@ export function kimiPrimaryAgentWirePath( return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl') } -type WorkDirCacheEntry = { - mtimeMs: number - map: Promise> -} - // Why: every session under one Kimi home shares a single session_index.jsonl. -// Re-reading it once per session would be O(n^2); memoize by path + mtime so a -// scan reads the index at most once and the cache self-invalidates when Kimi -// appends a new session (mtime bump). -const workDirCacheByIndexPath = new Map() +// Re-reading it once per session would be O(n^2); memoize by path + file +// identity so a scan reads the index at most once. Bound and expire entries +// because host/WSL/runtime roots can change during one main-process lifetime. +const workDirCacheByIndexPath = new KimiSessionIndexCache() export function clearKimiSessionIndexCache(): void { workDirCacheByIndexPath.clear() } +export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolean { + return workDirCacheByIndexPath.has(indexPath) +} + export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { - let mtimeMs: number + const generation = workDirCacheByIndexPath.beginRead() + let identity: Awaited> try { - mtimeMs = (await stat(indexPath)).mtimeMs + identity = await stat(indexPath) } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. + workDirCacheByIndexPath.delete(indexPath, generation) return new Map() } - const cached = workDirCacheByIndexPath.get(indexPath) - if (cached && cached.mtimeMs === mtimeMs) { - return cached.map - } - - const map = parseKimiSessionIndex(indexPath) - workDirCacheByIndexPath.set(indexPath, { mtimeMs, map }) - return map + return workDirCacheByIndexPath.get( + indexPath, + { + changeTimeMs: identity.ctimeMs, + mtimeMs: identity.mtimeMs, + sizeBytes: identity.size + }, + generation, + () => parseKimiSessionIndex(indexPath) + ) } async function parseKimiSessionIndex(indexPath: string): Promise> { From 884781a5527f2b782b8df2a388dae9b22d132530 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:34:41 -0700 Subject: [PATCH 074/138] Contain smart-sort persistence failures on remote disconnect (#6994) --- .../src/components/sidebar/WorktreeList.tsx | 22 +---- .../worktree-sort-order-persistence.test.ts | 84 +++++++++++++++++++ .../lib/worktree-sort-order-persistence.ts | 35 ++++++++ 3 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/worktree-sort-order-persistence.test.ts create mode 100644 src/renderer/src/lib/worktree-sort-order-persistence.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 54806828e43..83f6b225e26 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -203,14 +203,12 @@ import { pruneWorktreeSelection, updateWorktreeSelection } from './worktree-multi-selection' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { splitWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-host-split' +import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, - type ExecutionHostId, - parseExecutionHostId + type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -5509,21 +5507,7 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: sortOrder is persisted in each host's worktreeMeta and enriched from // the owner host, so persist each host's ids on that host. const state = useAppStore.getState() - for (const group of splitWorktreeSortOrderByHost(state, sortedIds)) { - const parsed = parseExecutionHostId(group.hostId) - const target = - parsed?.kind === 'runtime' - ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) - : ({ kind: 'local' } as const) - void (target.kind === 'environment' - ? callRuntimeRpc( - target, - 'worktree.persistSortOrder', - { orderedIds: group.orderedIds }, - { timeoutMs: 15_000 } - ) - : window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds })) - } + persistWorktreeSortOrderByHost(state, sortedIds) }, [sortedIds, sortBy]) // Flatten, filter, and apply stable sort order via the shared utility so diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.test.ts b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts new file mode 100644 index 00000000000..150194a572b --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { persistWorktreeSortOrderByHost } from './worktree-sort-order-persistence' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const localPersistSortOrder = vi.fn() +const runtimeCall = vi.mocked(callRuntimeRpc) +const UNHANDLED_REJECTION_SETTLE_MS = 20 + +const state: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }], + 'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }] + } +} + +async function collectUnhandledRejections(run: () => void): Promise { + const reasons: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + reasons.push(reason) + } + + process.on('unhandledRejection', onUnhandledRejection) + try { + run() + await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + + return reasons +} + +beforeEach(() => { + runtimeCall.mockReset() + localPersistSortOrder.mockReset() + runtimeCall.mockResolvedValue(undefined) + localPersistSortOrder.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + worktrees: { + persistSortOrder: localPersistSortOrder + } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('persistWorktreeSortOrderByHost', () => { + it('persists each owner host through the matching transport', () => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + + expect(runtimeCall).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'worktree.persistSortOrder', + { orderedIds: ['runtime-repo::wt-b'] }, + { timeoutMs: 15_000 } + ) + expect(localPersistSortOrder).toHaveBeenCalledWith({ orderedIds: ['local-repo::wt-a'] }) + }) + + it('handles best-effort persistence rejections from disconnected hosts', async () => { + runtimeCall.mockRejectedValueOnce(new Error('SSH disconnected')) + localPersistSortOrder.mockRejectedValueOnce(new Error('local store unavailable')) + + const unhandledRejections = await collectUnhandledRejections(() => { + persistWorktreeSortOrderByHost(state, ['runtime-repo::wt-b', 'local-repo::wt-a']) + }) + + expect(unhandledRejections).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-sort-order-persistence.ts b/src/renderer/src/lib/worktree-sort-order-persistence.ts new file mode 100644 index 00000000000..28e2d8d3556 --- /dev/null +++ b/src/renderer/src/lib/worktree-sort-order-persistence.ts @@ -0,0 +1,35 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split' + +function ignoreSortOrderPersistenceFailure(promise: Promise): void { + void promise.catch(() => { + // Why: sort-order restore is best-effort; SSH disconnects during smart sort + // must not surface as unhandled rejections that pollute crash diagnostics. + }) +} + +export function persistWorktreeSortOrderByHost( + state: WorktreeRuntimeOwnerState, + orderedIds: readonly string[] +): void { + for (const group of splitWorktreeSortOrderByHost(state, orderedIds)) { + const parsed = parseExecutionHostId(group.hostId) + if (parsed?.kind === 'runtime') { + ignoreSortOrderPersistenceFailure( + callRuntimeRpc( + { kind: 'environment', environmentId: parsed.environmentId }, + 'worktree.persistSortOrder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + ) + continue + } + + ignoreSortOrderPersistenceFailure( + window.api.worktrees.persistSortOrder({ orderedIds: group.orderedIds }) + ) + } +} From b5cdab3a26014058a0d66c9f00e8c848d7616710 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:17 -0700 Subject: [PATCH 075/138] Fix tracked upstream generation cache leak (#7674) --- src/main/github/client.test.ts | 114 +++++++++++++++++++++++++++++++++ src/main/github/client.ts | 50 +++++++++++++-- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 5363cb56c60..c0a3c58be37 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -118,6 +118,7 @@ import { updatePRState, updatePRTitle, _getMergeQueueCacheSizeForTests, + _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests @@ -1786,6 +1787,119 @@ describe('getPRForBranch', () => { expect(trackedUpstreamCalls).toHaveLength(1) }) + it('releases tracked-upstream probe generations across runtime identities', async () => { + const sshGitProvider = { + exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + } + getSshGitProviderMock.mockReturnValue(sshGitProvider) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await getPRForBranch('/repo-root', 'feature', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 3, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) + }) + + it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + for (let index = 0; index < 513; index += 1) { + await getPRForBranch(`/repo-root-${index}`, 'feature') + } + await getPRForBranch('/repo-root-512', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 512, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) + + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root-fresh', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale probe cleanup from releasing a replacement generation', async () => { + let resolveOldProbe: (value: { stdout: string; stderr: string }) => void + let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldProbe = resolve + }) + ) + .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrentProbe = resolve + }) + ) + + const oldLookup = getPRForBranch('/repo-root', 'old') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) + __resetTrackedUpstreamBranchCacheForTests() + await getPRForBranch('/repo-root', 'replacement') + const currentLookup = getPRForBranch('/repo-root', 'current') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) + + resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) + await oldLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 1, + generations: 1 + }) + + resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) + await currentLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + }) + + it('releases tracked-upstream probe state when setup rejects', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) + + await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 0, + generations: 0 + }) + }) + it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e4005421a4..b4702f28128 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2392,6 +2392,7 @@ type TrackedUpstreamBranch = { } const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 +const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number @@ -2411,12 +2412,49 @@ const trackedUpstreamSnapshotInFlight = new Map< string, Promise >() -const trackedUpstreamSnapshotGenerations = new Map() +const trackedUpstreamSnapshotGenerations = new Map() -function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number { - const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1 - trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration) - return nextGeneration +function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { + const generation = Symbol() + trackedUpstreamSnapshotGenerations.set(cacheKey, generation) + return generation +} + +function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { + // Why: generations only guard an active probe; retaining completed repo keys + // leaks worktree/runtime identities after the short-lived snapshot TTL expires. + if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { + trackedUpstreamSnapshotGenerations.delete(cacheKey) + } +} + +function pruneTrackedUpstreamSnapshotCache(now: number): void { + for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { + if (cached.expiresAt <= now) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + } + // Why: workspace/runtime churn can create unbounded unique keys within one + // TTL window, so expiry sweeping alone is not a memory bound. + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldestKey = trackedUpstreamSnapshotCache.keys().next().value + if (oldestKey === undefined) { + break + } + trackedUpstreamSnapshotCache.delete(oldestKey) + } +} + +export function _getTrackedUpstreamBranchCacheSizesForTests(): { + snapshots: number + inFlight: number + generations: number +} { + return { + snapshots: trackedUpstreamSnapshotCache.size, + inFlight: trackedUpstreamSnapshotInFlight.size, + generations: trackedUpstreamSnapshotGenerations.size + } } export function __resetTrackedUpstreamBranchCacheForTests(): void { @@ -2510,6 +2548,7 @@ async function getTrackedUpstreamBranch( upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) + pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) @@ -2522,6 +2561,7 @@ async function getTrackedUpstreamBranch( if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { trackedUpstreamSnapshotInFlight.delete(cacheKey) } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) } } From 11e537616cdc56d8863bc9a779d5d10f1946e0b7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:31 -0700 Subject: [PATCH 076/138] Surface a friendly error when Windows blocks the user PATH write (#6412) (#6588) * Surface a friendly error when Windows blocks the user PATH write When HKCU\Environment is locked (Group Policy / restricted ACL / EDR-managed environment), the PowerShell SetEnvironmentVariable write rejects with a raw 'Command failed: powershell ...' error (often mojibake-localized) that the CLI Settings toast displayed verbatim. Detect the permission failure via the Latin PowerShell error-envelope tokens and rethrow an actionable message naming the exact folder to add to PATH manually, chaining the original error on cause. Fixes #6412 Co-authored-by: Orca * Tighten Windows PATH permission detection --------- Co-authored-by: Orca --- src/main/cli/cli-installer.test.ts | 82 ++++++++++++++++++++++++++++++ src/main/cli/cli-installer.ts | 56 ++++++++++++++++++-- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 4ce919cef6b..712659fa173 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -346,6 +346,88 @@ describe('CliInstaller', () => { expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) + it.each(['UnauthorizedAccessException', 'SecurityException'])( + 'rejects with a friendly message for Windows PATH denial: %s', + async (permissionMarker) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + // The .NET error id survives localized or mojibake PowerShell output. + const error = new Error( + `Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nFullyQualifiedErrorId : ${permissionMarker},Microsoft.PowerShell.Commands` + ) + Object.assign(error, { code: 1 }) + throw error + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(/access denied|Group Policy|manually/i) + await expect(result).rejects.not.toThrow(/Command failed: powershell/) + await expect(result).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(permissionMarker) + }) + }) + } + ) + + it('skips the Windows PATH write when removing an absent entry', async () => { + const fixture = await makeFixture() + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter + }) + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it.each([ + ['PowerShell timeout', 'Windows PATH command timed out after 5000ms.'], + [ + 'generic PowerShell method failure', + "Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nCategoryInfo : NotSpecified: (:) [], MethodInvocationException\nFullyQualifiedErrorId : MethodInvocationException" + ] + ])( + 'propagates a non-permission Windows PATH write error unchanged: %s', + async (_name, message) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + throw new Error(message) + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(message) + await expect(result).rejects.not.toThrow(/Windows blocked updating your user PATH/) + } + ) + it('settles when the Windows PATH query hangs', async () => { vi.useFakeTimers() const fixture = await makeFixture() diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6765d8249b1..0705c4aaee5 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -823,7 +823,7 @@ export class CliInstaller { return } entries.push(pathDirectory) - await this.userPathWriter(entries.join(';')) + await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add') } private async removeWindowsPathEntry(pathDirectory: string): Promise { @@ -831,10 +831,36 @@ export class CliInstaller { return } const current = await this.userPathReader() - const nextEntries = splitPathEntries('win32', current).filter( - (entry) => !samePathEntry('win32', entry, pathDirectory) - ) - await this.userPathWriter(nextEntries.join(';')) + const entries = splitPathEntries('win32', current) + const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + if (nextEntries.length === entries.length) { + return + } + await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') + } + + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes + // while preserving the original diagnostic as the error cause. + private async writeWindowsUserPathEntry( + value: string, + pathDirectory: string, + action: 'add' | 'remove' + ): Promise { + try { + await this.userPathWriter(value) + } catch (error) { + if (!isWindowsUserPathPermissionError(error)) { + throw error + } + const guidance = + action === 'add' + ? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + : `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + throw new Error( + `Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`, + { cause: error } + ) + } } } @@ -1038,6 +1064,26 @@ function isMissingError(error: unknown): boolean { ) } +// Why: localized permission errors retain these .NET/ACL markers even when +// their human-readable PowerShell text is mojibake. +function isWindowsUserPathPermissionError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const stderr = + 'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr + : '' + const haystack = `${error.message}\n${stderr}` + return ( + haystack.includes('UnauthorizedAccessException') || + haystack.includes('SecurityException') || + haystack.includes('Requested registry access is not allowed') || + haystack.includes('Access is denied') || + haystack.includes('Access to the registry key') + ) +} + function quoteShell(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` } From c3ccbdc30664c396de4b8e530d884ecb16e35c0e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:44:10 -0700 Subject: [PATCH 077/138] Fix worktree scan generation cache leak (#7675) --- src/main/git/worktree.test.ts | 130 +++++++++++++++++++++++++++++++++- src/main/git/worktree.ts | 39 ++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 54216ab9ad7..035a7f77405 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -23,6 +23,8 @@ import { clearGitCapabilityStateForTests } from './git-capability-state' import { addSparseWorktree, addWorktree, + _getWorktreeScanCacheSizesForTests, + _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees, moveWorktree, @@ -36,7 +38,13 @@ beforeEach(() => { }) describe('listWorktrees in-flight sharing', () => { - beforeEach(() => { + beforeEach(async () => { + gitExecFileAsyncMock.mockReset() + _resetWorktreeScanCacheForTests() + // Why: capability discovery serializes same-host callers; warming it lets + // these tests isolate the scan-generation overlap they are exercising. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await listWorktrees('/capability-warmup') gitExecFileAsyncMock.mockReset() }) @@ -87,6 +95,126 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('does not retain scan generations after mutations without active scans', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await Promise.all( + Array.from({ length: 128 }, (_, index) => + moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`) + ) + ) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('cleans mutation generations after stale scans settle and supports repo reuse', async () => { + const scanResolvers: ((stdout: string) => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push((stdout) => resolve({ stdout })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleScan = listWorktrees('/repo') + expect(scanResolvers).toHaveLength(1) + + await moveWorktree('/repo', '/repo-old', '/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + const freshScan = listWorktrees('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + + scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') + expect((await freshScan)[0]?.path).toBe('/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') + expect((await staleScan)[0]?.path).toBe('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + + const firstReuse = listWorktrees('/repo') + const secondReuse = listWorktrees('/repo') + expect(listCalls).toBe(3) + + scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n') + const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse]) + expect(firstResult).toEqual(secondResult) + expect(firstResult[0]?.path).toBe('/repo-reused') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('retires every overlapping scan generation across repeated mutations', async () => { + const scanResolvers: (() => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const oldestScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-0', '/new-0') + const middleScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-1', '/new-1') + const newestScan = listWorktrees('/repo') + + expect(listCalls).toBe(3) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + + scanResolvers[0]?.() + scanResolvers[2]?.() + scanResolvers[1]?.() + await Promise.all([oldestScan, middleScan, newestScan]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('keeps WSL scans distinct while retiring both after a mutation', async () => { + const scanResolvers: (() => void)[] = [] + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await Promise.all([ + listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }), + listWorktrees('/capability-warmup', { wslDistro: 'Debian' }) + ]) + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(2) + + await removeWorktree('/repo', '/worktree', false, { + knownRemovedWorktree: { branch: '', head: '' }, + wslDistro: 'Ubuntu' + }) + const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(4) + + for (const resolve of scanResolvers) { + resolve() + } + await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + it('does not share scans for callers with an AbortSignal', async () => { const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 37c017c04d5..c9175b3dcb4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -650,10 +650,48 @@ const inFlightWorktreeScans = new Map>() // settle for their original callers). const worktreeScanGenerations = new Map() +function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { + const keyPrefix = `${repoPath}\0` + for (const key of inFlightWorktreeScans.keys()) { + if (key.startsWith(keyPrefix)) { + return true + } + } + return false +} + function bumpWorktreeScanGeneration(repoPath: string): void { + // Why: generations only prevent joining a pre-mutation scan. Without an + // active scan, retaining the repo path just leaks completed mutation keys. + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + return + } worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) } +function pruneWorktreeScanGeneration(repoPath: string): void { + // Why: ordinary scan settlement should stay O(1); only repos invalidated + // during an active scan need the cross-generation in-flight check. + if (!worktreeScanGenerations.has(repoPath)) { + return + } + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + worktreeScanGenerations.delete(repoPath) + } +} + +export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } { + return { + inFlight: inFlightWorktreeScans.size, + generations: worktreeScanGenerations.size + } +} + +export function _resetWorktreeScanCacheForTests(): void { + inFlightWorktreeScans.clear() + worktreeScanGenerations.clear() +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -676,6 +714,7 @@ export function listWorktrees( if (inFlightWorktreeScans.get(key) === scan) { inFlightWorktreeScans.delete(key) } + pruneWorktreeScanGeneration(repoPath) }) inFlightWorktreeScans.set(key, scan) return scan From 19102355d78e65719650a0d2f7177ccb93847a64 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:45:50 -0700 Subject: [PATCH 078/138] perf(renderer): keep worktree activity summaries ref-stable across unrelated churn (#7626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectWorktreeAgentActivitySummary's shared cache keys on agentStatusEpoch, so any epoch bump — an UNRELATED worktree's agent transition, or a freshness tick — invalidated it and rebuilt every worktree's summary with fresh objects/Sets. Each sidebar card subscribes to its own summary via useShallow, and the new nested agentStatusPaneIdsByTabId ref defeated the shallow compare, re-rendering every card with agents on every transition anywhere. Reuse the prior summary object when structurally equal (booleans + Set-membership), mirroring reuseArrayIfEqual, so only the transitioning card re-renders. Co-authored-by: Orca --- .../worktree-agent-activity-summary.test.ts | 114 ++++++++++++++++++ .../worktree-agent-activity-summary.ts | 54 +++++++++ 2 files changed, 168 insertions(+) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 6b03422890c..26c5d2828df 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' @@ -164,6 +165,119 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('limits summary-reference churn to the transitioning worktree at scale', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const worktreeIds = Array.from({ length: 12 }, (_, index) => `repo::/wt-${index}`) + const tabsByWorktree = Object.fromEntries( + worktreeIds.map((worktreeId, index) => [worktreeId, [makeTab(`tab-${index}`, worktreeId)]]) + ) + const initialStatuses = Object.fromEntries( + worktreeIds.map((_, index) => { + const paneKey = makePaneKey(`tab-${index}`, LEAF_ID) + return [paneKey, makeAgentStatusEntry({ paneKey, state: 'working' })] + }) + ) + const changedPaneKey = makePaneKey('tab-11', LEAF_ID) + const baseInputs = { + tabsByWorktree, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + const state: AgentActivityInput = { + ...baseInputs, + agentStatusEpoch: 0, + agentStatusByPaneKey: initialStatuses + } + const changedState: AgentActivityInput = { + ...baseInputs, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + ...initialStatuses, + [changedPaneKey]: makeAgentStatusEntry({ paneKey: changedPaneKey, state: 'done' }) + } + } + + const before = worktreeIds.map((worktreeId) => + selectWorktreeAgentActivitySummary(state, worktreeId) + ) + const after = worktreeIds.map((worktreeId) => + selectWorktreeAgentActivitySummary(changedState, worktreeId) + ) + const changedReferenceCount = after.filter((summary, index) => summary !== before[index]).length + const shallowNotificationCount = after.filter( + (summary, index) => !shallow(summary, before[index]) + ).length + + // Why: shallow store subscriptions wake on the nested pane-id map reference. + // One transition must not schedule downstream work for every other card. + expect(changedReferenceCount).toBe(1) + expect(shallowNotificationCount).toBe(1) + expect(after[11]).toMatchObject({ hasLiveWorking: false, hasLiveDone: true }) + }) + + it('reuses only summaries whose pane membership is still current', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const worktreeId = 'repo::/wt-1' + const firstPaneKey = makePaneKey('tab-1', LEAF_ID) + const secondPaneKey = makePaneKey('tab-2', LEAF_ID) + const replacementPaneKey = makePaneKey('tab-3', LEAF_ID) + const sharedInputs = { + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + const initial: AgentActivityInput = { + ...sharedInputs, + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId), makeTab('tab-2', worktreeId)] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [firstPaneKey]: makeAgentStatusEntry({ paneKey: firstPaneKey, state: 'working' }), + [secondPaneKey]: makeAgentStatusEntry({ paneKey: secondPaneKey, state: 'working' }) + } + } + const reordered: AgentActivityInput = { + ...initial, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [secondPaneKey]: initial.agentStatusByPaneKey[secondPaneKey], + [firstPaneKey]: initial.agentStatusByPaneKey[firstPaneKey] + } + } + const replacement: AgentActivityInput = { + ...sharedInputs, + tabsByWorktree: { [worktreeId]: [makeTab('tab-3', worktreeId)] }, + agentStatusEpoch: 2, + agentStatusByPaneKey: { + [replacementPaneKey]: makeAgentStatusEntry({ + paneKey: replacementPaneKey, + state: 'working' + }) + } + } + const removed: AgentActivityInput = { + ...replacement, + agentStatusEpoch: 3, + agentStatusByPaneKey: {} + } + + const first = selectWorktreeAgentActivitySummary(initial, worktreeId) + const afterReorder = selectWorktreeAgentActivitySummary(reordered, worktreeId) + const afterReplacement = selectWorktreeAgentActivitySummary(replacement, worktreeId) + const afterRemoval = selectWorktreeAgentActivitySummary(removed, worktreeId) + + expect(afterReorder).toBe(first) + expect(afterReplacement).not.toBe(first) + expect(afterReplacement.agentStatusPaneIdsByTabId).toEqual({ + 'tab-3': new Set([LEAF_ID]) + }) + expect(afterRemoval).not.toBe(afterReplacement) + expect(afterRemoval).toMatchObject({ hasLiveWorking: false }) + expect(afterRemoval.agentStatusPaneIdsByTabId).toEqual({}) + }) + it('summarizes worktree-attributed rows missing from the tab list', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const childPaneKey = makePaneKey('tab-child', '22222222-2222-4222-8222-222222222222') diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index ac44c11b717..6802a3603cf 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -139,6 +139,18 @@ function getWorktreeAgentActivitySummaries( addParentPaneId(summary, orchestration, retained.worktreeId, tabIdToWorktreeId) } + // Why: epoch changes rebuild every summary, so reuse structurally equal results + // to keep unrelated worktree subscriptions from scheduling card renders. + const previousSummaries = agentActivityCache?.summaries + if (previousSummaries) { + for (const [worktreeId, summary] of summaries) { + const previous = previousSummaries.get(worktreeId) + if (previous && summariesEqual(previous, summary)) { + summaries.set(worktreeId, previous) + } + } + } + agentActivityCache = { tabsByWorktree: state.tabsByWorktree, agentStatusEpoch: state.agentStatusEpoch, @@ -150,6 +162,48 @@ function getWorktreeAgentActivitySummaries( return summaries } +function summariesEqual( + previous: WorktreeAgentActivitySummary, + next: WorktreeAgentActivitySummary +): boolean { + return ( + previous.hasPermission === next.hasPermission && + previous.hasLiveWorking === next.hasLiveWorking && + previous.hasLiveDone === next.hasLiveDone && + previous.hasRetainedDone === next.hasRetainedDone && + agentStatusPaneIdsByTabIdEqual( + previous.agentStatusPaneIdsByTabId, + next.agentStatusPaneIdsByTabId + ) + ) +} + +function agentStatusPaneIdsByTabIdEqual( + previous: Record>, + next: Record> +): boolean { + if (previous === next) { + return true + } + const previousKeys = Object.keys(previous) + if (previousKeys.length !== Object.keys(next).length) { + return false + } + for (const tabId of previousKeys) { + const previousPaneIds = previous[tabId] + const nextPaneIds = next[tabId] + if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) { + return false + } + for (const paneId of previousPaneIds) { + if (!nextPaneIds.has(paneId)) { + return false + } + } + } + return true +} + function resolveEntryOrchestration( entry: Pick, runtimeOrchestration: AgentStatusOrchestrationContext | undefined From 6d3ab9f971fc8a84286ed94e44ff69862dd739eb Mon Sep 17 00:00:00 2001 From: Marius Date: Sat, 11 Jul 2026 22:45:52 +0200 Subject: [PATCH 079/138] feat(status-bar): choose used or remaining usage (#7574) Add a persisted Appearance setting that switches provider usage labels between percent used and percent remaining while keeping meter fill consumption-based. Cover desktop and web persistence, current providers, settings search, localized copy, and regression tests. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> --- src/main/persistence.ts | 7 ++ src/main/runtime/rpc/methods/client-ui.ts | 1 + .../settings/AppearancePane.test.tsx | 20 +++++ .../AppearanceWindowSidebarSection.tsx | 60 +++++++++++++-- .../components/settings/appearance-search.ts | 10 ++- .../appearance-usage-percentage-search.ts | 18 +++++ .../src/components/status-bar/StatusBar.tsx | 76 ++++++++++++------- .../status-bar/inline-usage-bars.test.tsx | 37 ++++++++- .../status-bar-provider-menu-focus.test.tsx | 14 +++- .../src/components/status-bar/tooltip.test.ts | 32 ++++++++ .../src/components/status-bar/tooltip.tsx | 15 ++-- .../status-bar/usage-percentage-label.test.ts | 15 ++++ .../status-bar/usage-percentage-label.ts | 19 +++++ src/renderer/src/i18n/locales/en.json | 12 ++- src/renderer/src/i18n/locales/es.json | 12 ++- src/renderer/src/i18n/locales/ja.json | 12 ++- src/renderer/src/i18n/locales/ko.json | 12 ++- src/renderer/src/i18n/locales/zh.json | 12 ++- src/renderer/src/store/slices/ui.test.ts | 26 +++++++ src/renderer/src/store/slices/ui.ts | 14 ++++ src/renderer/src/web/web-preload-api.ts | 4 + src/shared/constants.ts | 2 + src/shared/types.ts | 3 + src/shared/usage-percentage-display.test.ts | 25 ++++++ src/shared/usage-percentage-display.ts | 21 +++++ 25 files changed, 423 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-usage-percentage-search.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-percentage-label.ts create mode 100644 src/shared/usage-percentage-display.test.ts create mode 100644 src/shared/usage-percentage-display.ts diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0cac89b3f17..19cce1bbacb 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -104,6 +104,7 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -5333,6 +5334,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + this.state.ui?.usagePercentageDisplay + ), // Why: strict boolean coercion so a missing/legacy value reads as false // (first-run notice still fires) rather than leaking a non-bool through. trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, @@ -5412,6 +5416,9 @@ export class Store { sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..271fcefad75 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -208,6 +208,7 @@ const UiUpdate = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 127df935ff7..dc5a4abd1aa 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ settingsSearchQuery: 'automations', statusBarItems: [], toggleStatusBarItem: vi.fn(), + usagePercentageDisplay: 'used' as 'used' | 'remaining', + setUsagePercentageDisplay: vi.fn(), recordFeatureInteraction: vi.fn(), setWorktreeCardMode: vi.fn() } @@ -176,6 +178,7 @@ describe('AppearancePane', () => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] mocks.state.settingsSearchQuery = 'automations' + mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. @@ -387,6 +390,23 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('updates the usage percentage display from the latest status bar settings section', async () => { + mocks.state.settingsSearchQuery = 'remaining' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const remainingButton = Array.from( + container.querySelectorAll('button[role="radio"]') + ).find((button) => button.textContent === 'Remaining') + + expect(container.textContent).toContain('Usage percentages') + expect(remainingButton).toBeDefined() + + await act(async () => { + remainingButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.setUsagePercentageDisplay).toHaveBeenCalledWith('remaining') + }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { mocks.state.availableStatusBarToggles = [ { diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1690832ee18..93b9f94535e 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -12,7 +12,12 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' -import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search' +import { + getLayoutEntries, + getSidebarEntries, + getStatusBarToggles, + getUsagePercentageDisplayEntry +} from './appearance-search' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, @@ -60,9 +65,12 @@ export function AppearanceWindowSidebarSection({ const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const statusBarItems = useAppStore((state) => state.statusBarItems) const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem) + const usagePercentageDisplay = useAppStore((state) => state.usagePercentageDisplay) + const setUsagePercentageDisplay = useAppStore((state) => state.setUsagePercentageDisplay) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode) const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles()) + const usagePercentageDisplayEntry = getUsagePercentageDisplayEntry() const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry() const sidebarEntries = getSidebarEntries() const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry() @@ -81,13 +89,15 @@ export function AppearanceWindowSidebarSection({ description: statusBarDescription, keywords: statusBarKeywords }) - const statusBarControlMatches = visibleStatusBarToggles.some((toggle) => - matchesSettingsSearch(searchQuery, { - title: toggle.title, - description: toggle.description, - keywords: toggle.keywords - }) - ) + const statusBarControlMatches = + matchesSettingsSearch(searchQuery, usagePercentageDisplayEntry) || + visibleStatusBarToggles.some((toggle) => + matchesSettingsSearch(searchQuery, { + title: toggle.title, + description: toggle.description, + keywords: toggle.keywords + }) + ) const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [ workspaceCardLayoutEntry, ...sidebarEntries @@ -119,6 +129,40 @@ export function AppearanceWindowSidebarSection({ {showStatusBarControls ? (
+ + + } + /> + + {visibleStatusBarToggles.map((toggle) => { const enabled = statusBarItems.includes(toggle.id) return ( diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 7284b5db315..3ceaf412d13 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -8,8 +8,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' +import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' -export { getStatusBarToggles } +export { getStatusBarToggles, getUsagePercentageDisplayEntry } export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -148,13 +149,14 @@ export const getTitlebarEntries = createLocalizedCatalog((): SettingsSearchEntry } ]) -export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => - getStatusBarToggles().map(({ title, description, keywords }) => ({ +export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ + getUsagePercentageDisplayEntry(), + ...getStatusBarToggles().map(({ title, description, keywords }) => ({ title, description, keywords })) -) +]) export { getLeftSidebarAppearanceEntry, getSidebarEntries } diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts new file mode 100644 index 00000000000..9375c7c9fd1 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -0,0 +1,18 @@ +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({ + title: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayTitle', + 'Usage percentages' + ), + description: translate( + 'auto.components.settings.appearance.search.usagePercentageDisplayDescription', + 'Choose whether provider limits show the percentage used or remaining.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.896eb53fd4', 'status bar') + ] +})) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index aa826573e73..281c968fa2f 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -79,6 +79,11 @@ import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' +import { + normalizeUsagePercentageDisplay, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' type StatusBarProps = { floatingTerminalOpen: boolean @@ -963,9 +968,11 @@ export function InlineUsageBars({ limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - // Why: show % used (consumption), not remaining — matches harness meters (#7551). - // Keep the "used" word in compact labels so bare "32% 5h" is not read as remaining. - const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used') + const display = normalizeUsagePercentageDisplay( + useAppStore((state) => state.usagePercentageDisplay) + ) + // Why: the preference changes copy, while bar fill stays consumption-based + // so empty/green and full/red keep the meter semantics introduced in #8167. const usageWindows = [ limits.session ? { @@ -1006,8 +1013,7 @@ export function InlineUsageBars({ />
- {window.used} - {usedSuffix} {window.label} + {formatUsagePercentageLabel(window.used, display)} {window.label} ))} @@ -1080,16 +1086,21 @@ function InlineUsageSkeleton(): React.JSX.Element { } // --------------------------------------------------------------------------- -// Window label (shows percent used / consumption) +// Window label // --------------------------------------------------------------------------- -function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element { - const used = clampUsedPercent(w.usedPercent) - // Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit. +function WindowLabel({ + w, + label, + display +}: { + w: RateLimitWindow + label: string + display: UsagePercentageDisplay +}): React.JSX.Element { return ( - {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label} + {formatUsagePercentageLabel(w.usedPercent, display)} {label} ) } @@ -1104,10 +1115,12 @@ const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) function ProviderSegment({ p, - compact + compact, + display }: { p: ProviderRateLimits | null compact: boolean + display: UsagePercentageDisplay }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' @@ -1160,20 +1173,20 @@ function ProviderSegment({ return ( - {visibleBuckets.map((bucket, i) => { - const used = clampUsedPercent(bucket.usedPercent) - return ( - - {i > 0 && ·} - - {bucket.name} {used} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - - - ) - })} + {visibleBuckets.map((bucket, i) => ( + + {i > 0 && ·} + + {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} + + + ))} {visibleBuckets.length === 0 && p.session && ( - + )} {isStale && } @@ -1211,7 +1224,7 @@ function ProviderSegment({ {visibleWindows.map((window, index) => ( {index > 0 && ·} - + ))} {isStale && } @@ -1745,6 +1758,9 @@ export function ProviderDetailsMenu({ children?: React.ReactNode }): React.JSX.Element { const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const usagePercentageDisplay = normalizeUsagePercentageDisplay( + useAppStore((s) => s.usagePercentageDisplay) + ) const skipCloseAutoFocusRef = useRef(false) const handleOpenChange = (nextOpen: boolean): void => { @@ -1787,7 +1803,7 @@ export function ProviderDetailsMenu({ ) : ( - + )} @@ -1812,7 +1828,11 @@ export function ProviderDetailsMenu({ {topContent}
{/* Why: provider-specific action sections may render richer reset-credit UI. */} - +
{children ? ( <> diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index 018d260768f..404caa344dc 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -1,16 +1,31 @@ import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' vi.mock('@/i18n/i18n', () => ({ i18n: { language: 'en' }, - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +const mocks = vi.hoisted(() => ({ + usagePercentageDisplay: 'used' as 'used' | 'remaining' +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: mocks.usagePercentageDisplay }) +})) + function claudeLimits(): ProviderRateLimits { return { provider: 'claude', @@ -39,6 +54,10 @@ function claudeLimits(): ProviderRateLimits { } describe('InlineUsageBars', () => { + beforeEach(() => { + mocks.usagePercentageDisplay = 'used' + }) + it('renders Claude Fable usage in inactive account preview rows', async () => { const { InlineUsageBars } = await import('./StatusBar') @@ -51,4 +70,18 @@ describe('InlineUsageBars', () => { expect(markup).toContain('16% used wk') expect(markup).toContain('42% used Fable') }) + + it('shows remaining copy without reversing consumption meter fill', async () => { + mocks.usagePercentageDisplay = 'remaining' + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% left 5h') + expect(markup).toContain('84% left wk') + expect(markup).toContain('58% left Fable') + expect(markup).toContain('width:32%') + }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx index 25dfa651bc5..a72761322a5 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' const recordFeatureInteractionMock = vi.fn() +const usagePercentageDisplayMock = 'used' vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() @@ -17,8 +18,15 @@ vi.mock('react', async () => { vi.mock('../../store', () => ({ useAppStore: ( - selector: (state: { recordFeatureInteraction: typeof recordFeatureInteractionMock }) => unknown - ) => selector({ recordFeatureInteraction: recordFeatureInteractionMock }) + selector: (state: { + recordFeatureInteraction: typeof recordFeatureInteractionMock + usagePercentageDisplay: 'used' + }) => unknown + ) => + selector({ + recordFeatureInteraction: recordFeatureInteractionMock, + usagePercentageDisplay: usagePercentageDisplayMock + }) })) vi.mock('./tooltip', () => ({ @@ -108,8 +116,10 @@ describe('ProviderDetailsMenu focus handoff', () => { const element = await renderProviderDetailsMenu() const dropdown = findChildByType(element, 'DropdownMenu') const content = findChildByType(element, 'DropdownMenuContent') + const providerPanel = findChildByType(element, 'ProviderPanel') expect(dropdown.props.modal).toBe(false) + expect(providerPanel.props.usagePercentageDisplay).toBe('used') const preventDefault = vi.fn() ;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 8f214ecc7fa..c553f68ddb1 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -45,6 +45,17 @@ function provider(overrides: Partial = {}): ProviderRateLimi } } +const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok' +] + afterEach(() => { vi.useRealTimers() }) @@ -473,6 +484,27 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('width:100%') expect(markup).not.toContain('140%') }) + + it.each(PROVIDER_IDS)( + 'applies remaining copy to %s while retaining consumption bar direction', + (providerId) => { + const p = provider({ + provider: providerId, + status: 'ok', + session: { + usedPercent: 25, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + + expect(markup).toContain('75% left') + expect(markup).toContain('width:25%') + } + ) }) describe('clampUsedPercent', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 47ba6414267..7890529bde2 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -7,6 +7,8 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { formatUsagePercentageLabel } from './usage-percentage-label' export { getProviderDisplayName, @@ -213,12 +215,14 @@ export function ProviderPanel({ p, inverted = false, className, - showResetCredits = true + showResetCredits = true, + usagePercentageDisplay = 'used' }: { p: ProviderRateLimits | null inverted?: boolean className?: string showResetCredits?: boolean + usagePercentageDisplay?: UsagePercentageDisplay }): React.JSX.Element { const textClass = inverted ? 'text-background' : 'text-foreground' const mutedClass = inverted ? 'text-background/60' : 'text-muted-foreground' @@ -288,8 +292,8 @@ export function ProviderPanel({ if (!w) { return null } - // Why: show % used (consumption), not remaining — matches Claude/Codex - // harness meters and avoids the "full green bar = exhausted" misread (#7551). + // Why: preference changes the copy only; consumption-based bar direction + // preserves the empty/green to full/red meter convention from #8167. const usedPct = clampUsedPercent(w.usedPercent) const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null @@ -303,10 +307,7 @@ export function ProviderPanel({ />
- - {usedPct} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} - + {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} {resetLabel && {resetLabel}}
diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.test.ts b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts new file mode 100644 index 00000000000..e994bf6b034 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +import { formatUsagePercentageLabel } from './usage-percentage-label' + +describe('formatUsagePercentageLabel', () => { + it('formats used and remaining percentages without changing source semantics', () => { + expect(formatUsagePercentageLabel(8, 'used')).toBe('8% used') + expect(formatUsagePercentageLabel(8, 'remaining')).toBe('92% left') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-percentage-label.ts b/src/renderer/src/components/status-bar/usage-percentage-label.ts new file mode 100644 index 00000000000..395c6831bf4 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-percentage-label.ts @@ -0,0 +1,19 @@ +import { translate } from '@/i18n/i18n' +import { + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' + +export function formatUsagePercentageLabel( + usedPercent: number, + display: UsagePercentageDisplay +): string { + const percentage = getDisplayedUsagePercentage(usedPercent, display) + return display === 'used' + ? translate('auto.components.status.bar.usagePercentageLabel.used', '{{value0}}% used', { + value0: String(percentage) + }) + : translate('auto.components.status.bar.usagePercentageLabel.remaining', '{{value0}}% left', { + value0: String(percentage) + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d9e2d1e3bb8..c9c058e3276 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH Host" + }, + "usagePercentageLabel": { + "used": "{{value0}}% used", + "remaining": "{{value0}}% left" } } }, @@ -7238,7 +7242,9 @@ "f8e2a1c4b6": "Grok Usage", "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Usage percentages", + "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Weekly credits", "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Used", + "usagePercentageDisplayRemaining": "Remaining" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e30c7b66f9f..c84e4f354d9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "Host SSH" + }, + "usagePercentageLabel": { + "used": "{{value0}}% usado", + "remaining": "{{value0}}% restante" } } }, @@ -7201,7 +7205,9 @@ "f8e2a1c4b6": "Uso de Grok", "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "Porcentajes de uso", + "usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "Créditos semanales", "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "Usado", + "usagePercentageDisplayRemaining": "Restante" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1651fbb09d2..6d481080c55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 使用済み", + "remaining": "残り {{value0}}%" } } }, @@ -7223,7 +7227,9 @@ "f8e2a1c4b6": "Grok の使用状況", "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "使用率", + "usagePercentageDisplayDescription": "プロバイダーの制限を使用済みまたは残りの割合で表示するかを選択します。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "週次クレジット", "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "使用済み", + "usagePercentageDisplayRemaining": "残り" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f5af8bbdb59..8e5fc8a4c5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" + }, + "usagePercentageLabel": { + "used": "{{value0}}% 사용", + "remaining": "{{value0}}% 남음" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 사용량", "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "사용량 백분율", + "usagePercentageDisplayDescription": "공급자 한도를 사용한 비율 또는 남은 비율로 표시할지 선택합니다." } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "주간 크레딧", "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "사용", + "usagePercentageDisplayRemaining": "남음" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c4eb2fdbe8f..d4e2f36f918 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3205,6 +3205,10 @@ }, "SshTargetStatusRow": { "sshHost": "SSH 主机" + }, + "usagePercentageLabel": { + "used": "已用 {{value0}}%", + "remaining": "剩余 {{value0}}%" } } }, @@ -7186,7 +7190,9 @@ "f8e2a1c4b6": "Grok 使用量", "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", "d6c0a9e2f4": "grok", - "c5b9f8d1e3": "xai" + "c5b9f8d1e3": "xai", + "usagePercentageDisplayTitle": "用量百分比", + "usagePercentageDisplayDescription": "选择以已用或剩余百分比显示提供商限额。" } }, "auto": { @@ -8994,6 +9000,10 @@ "a8f3e2c1b4": "每周额度", "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置" + }, + "AppearanceWindowSidebarSection": { + "usagePercentageDisplayUsed": "已用", + "usagePercentageDisplayRemaining": "剩余" } }, "right": { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 0f88ef4ca52..9e2e82e0cb4 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1260,6 +1260,32 @@ describe('createUISlice hydratePersistedUI', () => { expect(setUI).not.toHaveBeenCalled() }) + it('persists and hydrates the usage percentage display preference', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().setUsagePercentageDisplay('used') + + expect(store.getState().usagePercentageDisplay).toBe('used') + expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' }) + + store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' })) + expect(store.getState().usagePercentageDisplay).toBe('remaining') + }) + + it('defaults invalid usage percentage display values to used', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + usagePercentageDisplay: 'left' as PersistedUIState['usagePercentageDisplay'] + }) + ) + + expect(store.getState().usagePercentageDisplay).toBe('used') + }) + it('clamps persisted workspace board column width', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 205d31878cc..a0f6aa0db73 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -29,6 +29,11 @@ import type { WorkspaceHostScope, VisibleWorkspaceHostIds } from '../../../../shared/types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + DEFAULT_USAGE_PERCENTAGE_DISPLAY, + normalizeUsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../shared/task-source-context' @@ -869,6 +874,8 @@ export type UISlice = { toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void + usagePercentageDisplay: UsagePercentageDisplay + setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void workspacePortScan: { key: string; result: WorkspacePortScanResult } | null workspacePortScansByKey: Record workspacePortScanRefreshing: boolean @@ -2112,6 +2119,12 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ statusBarVisible: v }).catch(console.error) set({ statusBarVisible: v }) }, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + setUsagePercentageDisplay: (display) => { + const normalized = normalizeUsagePercentageDisplay(display) + window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error) + set({ usagePercentageDisplay: normalized }) + }, workspacePortScan: null, workspacePortScansByKey: {}, workspacePortScanRefreshing: false, @@ -2400,6 +2413,7 @@ export const createUISlice: StateCreator = (set, get) syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet // dismissal persists a `false` value. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index cd77c51acef..4e4b5502051 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -71,6 +71,7 @@ import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { @@ -3251,6 +3252,9 @@ function mergeWebUIState( safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, agentActivityDisplayMode: normalizeAgentActivityDisplayMode( safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay ) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f0b6c9dc04e..1a8a3f6ffe6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -31,6 +31,7 @@ import { import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -495,6 +496,7 @@ export function getDefaultUIState(): PersistedUIState { _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, dismissedUpdateVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 24308e9327f..fedd1623e46 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,7 @@ import type { GlobalWindowsRuntimeDefault, LocalWindowsRuntimePreference } from './project-execution-runtime' +import type { UsagePercentageDisplay } from './usage-percentage-display' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3311,6 +3312,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean + /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ + usagePercentageDisplay?: UsagePercentageDisplay dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null pendingUpdateNudgeId?: string | null diff --git a/src/shared/usage-percentage-display.test.ts b/src/shared/usage-percentage-display.test.ts new file mode 100644 index 00000000000..feae8c4371d --- /dev/null +++ b/src/shared/usage-percentage-display.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + getDisplayedUsagePercentage, + normalizeUsagePercentageDisplay +} from './usage-percentage-display' + +describe('usage percentage display', () => { + it('defaults unknown persisted values to the current used-capacity behavior', () => { + expect(normalizeUsagePercentageDisplay(undefined)).toBe('used') + expect(normalizeUsagePercentageDisplay('left')).toBe('used') + }) + + it('shows either the provider value or its complement', () => { + expect(getDisplayedUsagePercentage(6, 'used')).toBe(6) + expect(getDisplayedUsagePercentage(6, 'remaining')).toBe(94) + }) + + it('rounds and bounds percentages for display', () => { + expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21) + expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80) + expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0) + expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0) + expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0) + }) +}) diff --git a/src/shared/usage-percentage-display.ts b/src/shared/usage-percentage-display.ts new file mode 100644 index 00000000000..ad2603df820 --- /dev/null +++ b/src/shared/usage-percentage-display.ts @@ -0,0 +1,21 @@ +export type UsagePercentageDisplay = 'used' | 'remaining' + +// Why: missing settings preserve the consumption-meter behavior introduced in #8167. +export const DEFAULT_USAGE_PERCENTAGE_DISPLAY: UsagePercentageDisplay = 'used' + +export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentageDisplay { + return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY +} + +export function getDisplayedUsagePercentage( + usedPercent: number, + display: UsagePercentageDisplay +): number { + if (!Number.isFinite(usedPercent)) { + // Why: invalid provider data must not be presented as 100% remaining capacity. + return 0 + } + const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent)) + const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent + return Math.round(percentage) +} From a92fd26c96a9f5c14122dc14c14c683135138e2a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:46:11 -0700 Subject: [PATCH 080/138] perf(renderer): drive the agent-completion feedback observer off the agent-status epoch (#7621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-mounted observer that watches for successful agent completions subscribed to the whole agentStatusByPaneKey map, which is re-spread to a new object on every agent status ping (including high-frequency still-working pings). That re-rendered the observer and re-ran the app-wide done-transition scan on every ping. Subscribe to agentStatusEpoch instead — it bumps on exactly the state transitions the observer detects — and read the map imperatively via getState(), mirroring useActivityUnreadCount. Behavior is preserved; a Profiler render-count test guards that non-epoch churn no longer re-renders. Co-authored-by: Orca --- .../StarNagAgentValueMomentObserver.test.tsx | 69 ++++++++++++++++++- .../StarNagAgentValueMomentObserver.tsx | 36 +++++----- .../agent-status-worktree-purge-leak.test.ts | 2 + src/renderer/src/store/slices/worktrees.ts | 6 +- 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx index 4e1f7f5f4a0..3a689eac09a 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx @@ -1,12 +1,14 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { act, Profiler } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '@/store' import { StarNagAgentValueMomentObserver } from './StarNagAgentValueMomentObserver' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + type StarNagApi = { agentValueMoment: ReturnType showAgentValueMoment: ReturnType @@ -38,6 +40,25 @@ function renderObserver(): { root: Root; container: HTMLDivElement } { return { root, container } } +function renderObserverWithProfiler(): { + root: Root + container: HTMLDivElement + getRenderCount: () => number +} { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + let renderCount = 0 + act(() => { + root.render( + (renderCount += 1)}> + + + ) + }) + return { root, container, getRenderCount: () => renderCount } +} + function setAgentEntries(entries: Record): void { act(() => { useAppStore.setState((state) => ({ @@ -97,6 +118,52 @@ describe('StarNagAgentValueMomentObserver', () => { expect(showAgentValueMoment).toHaveBeenCalledTimes(1) }) + it('does not scan 500 agent statuses on 50 same-state working pings', () => { + const entries = Object.fromEntries( + Array.from({ length: 500 }, (_unused, index) => [ + `pane-${index}`, + entry({ paneKey: `tab-${index}:leaf-1` }) + ]) + ) + useAppStore.setState({ agentStatusByPaneKey: entries, agentStatusEpoch: 1 }) + const rendered = renderObserverWithProfiler() + root = rendered.root + container = rendered.container + const initialRenders = rendered.getRenderCount() + + // Why: separate acts model separate hook pings; main produced 50 renders and + // 25,000 completion-scan entry visits for this exact 500 x 50 scenario. + for (let ping = 0; ping < 50; ping += 1) { + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + 'pane-0': entry({ paneKey: 'tab-0:leaf-1', updatedAt: ping + 2 }) + } + })) + }) + } + expect(rendered.getRenderCount()).toBe(initialRenders) + + // A real transition bumps the epoch and must re-render. + setAgentEntries({ pane: entry({ state: 'done', updatedAt: 100 }) }) + expect(rendered.getRenderCount()).toBeGreaterThan(initialRenders) + }) + + it('does not treat a removed and reused pane key as one completion transition', () => { + ;({ root, container } = renderObserver()) + + setAgentEntries({ pane: entry({ state: 'working' }) }) + setAgentEntries({}) + setAgentEntries({ pane: entry({ state: 'done', updatedAt: 2 }) }) + act(() => { + vi.advanceTimersByTime(2400) + }) + + expect(agentValueMoment).not.toHaveBeenCalled() + expect(showAgentValueMoment).not.toHaveBeenCalled() + }) + it('ignores interrupted or empty-prompt completions', () => { ;({ root, container } = renderObserver()) diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 3dcd4b46c64..1fd3099323d 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from 'react' -import { useShallow } from 'zustand/react/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '@/store' @@ -48,14 +47,14 @@ function isTypingKeyEvent(event: KeyboardEvent): boolean { } export function StarNagAgentValueMomentObserver(): null { - const { agentStatusByPaneKey, agentStatusEpoch } = useAppStore( - useShallow((state) => ({ - agentStatusByPaneKey: state.agentStatusByPaneKey, - agentStatusEpoch: state.agentStatusEpoch - })) - ) + // Why: agentStatusByPaneKey is re-spread to a new object on every status ping + // (including high-frequency still-working pings that never change what we + // detect), so subscribing to the map re-rendered this always-mounted observer + // — and re-ran the app-wide done-transition scan — on every ping. agentStatusEpoch + // bumps on exactly the state transitions we care about, so drive off the epoch + // and read the map imperatively via getState(). + const agentStatusEpoch = useAppStore((state) => state.agentStatusEpoch) const previousEntriesRef = useRef(null) - const latestEntriesRef = useRef(agentStatusByPaneKey) const pendingRef = useRef(false) const requestedRef = useRef(false) const preparationRef = useRef(null) @@ -72,7 +71,10 @@ export function StarNagAgentValueMomentObserver(): null { return } const elapsedSinceTyping = Date.now() - lastTypingAtRef.current - if (hasActiveAgent(latestEntriesRef.current) || elapsedSinceTyping < QUIET_WINDOW_MS) { + if ( + hasActiveAgent(useAppStore.getState().agentStatusByPaneKey) || + elapsedSinceTyping < QUIET_WINDOW_MS + ) { scheduleCheck() return } @@ -86,7 +88,10 @@ export function StarNagAgentValueMomentObserver(): null { } } const freshElapsedSinceTyping = Date.now() - lastTypingAtRef.current - if (hasActiveAgent(latestEntriesRef.current) || freshElapsedSinceTyping < QUIET_WINDOW_MS) { + if ( + hasActiveAgent(useAppStore.getState().agentStatusByPaneKey) || + freshElapsedSinceTyping < QUIET_WINDOW_MS + ) { scheduleCheck() return } @@ -97,10 +102,6 @@ export function StarNagAgentValueMomentObserver(): null { }, CHECK_DELAY_MS) }, []) - useEffect(() => { - latestEntriesRef.current = agentStatusByPaneKey - }, [agentStatusByPaneKey, agentStatusEpoch]) - useEffect(() => { const markTyping = (event: Event): void => { if (event instanceof KeyboardEvent) { @@ -119,17 +120,18 @@ export function StarNagAgentValueMomentObserver(): null { }, []) useEffect(() => { + const currentEntries = useAppStore.getState().agentStatusByPaneKey const previousEntries = previousEntriesRef.current - previousEntriesRef.current = agentStatusByPaneKey + previousEntriesRef.current = currentEntries if (!previousEntries || requestedRef.current) { return } - if (!hasSuccessfulDoneTransition(previousEntries, agentStatusByPaneKey)) { + if (!hasSuccessfulDoneTransition(previousEntries, currentEntries)) { return } pendingRef.current = true scheduleCheck() - }, [agentStatusByPaneKey, agentStatusEpoch, scheduleCheck]) + }, [agentStatusEpoch, scheduleCheck]) useEffect(() => { return () => { diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts index 681c26f81c4..9052399a4e6 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts @@ -92,11 +92,13 @@ describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak r it('purgeWorktreeTerminalState drops every pane-scoped map for the removed worktree', () => { const store = createTestStore() seedPaneState(store) + const epochBefore = store.getState().agentStatusEpoch store.getState().purgeWorktreeTerminalState([WT]) const s = store.getState() expect(s.agentStatusByPaneKey[PANE]).toBeUndefined() + expect(s.agentStatusEpoch).toBe(epochBefore + 1) expect(s.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() expect(s.acknowledgedAgentsByPaneKey[PANE]).toBeUndefined() expect(s.paneForegroundAgentByPaneKey[PANE]).toBeUndefined() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 315e23aaf70..17b3444fe4d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2102,6 +2102,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial Date: Sat, 11 Jul 2026 13:59:28 -0700 Subject: [PATCH 081/138] Prune detected worktree scan generation markers (#7679) * Prune detected worktree scan generations * Invalidate pending scans in test reset hook Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/worktrees.test.ts | 199 ++++++++++++++++++++++++++++++++- src/main/ipc/worktrees.ts | 51 ++++++--- 2 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index db5bf3b1a4a..031f9cf703d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -246,7 +246,11 @@ import { notifyWorktreesChanged } from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { + __getDetectedWorktreeScanCacheStatsForTests, + __resetDetectedWorktreeScanCacheForTests, + registerWorktreeHandlers +} from './worktrees' type HandlerMap = Record unknown> @@ -2633,6 +2637,199 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(1) }) + it('does not retain invalidated detected scans after they settle', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 1 + }) + + notifyWorktreesChanged(mainWindow as never, 'repo-1') + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + await pendingList + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + }) + + it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { + store.getRepo.mockImplementation((repoId: string) => ({ + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + })) + listWorktreesMock.mockImplementation(async (repoPath: string) => [ + { + path: repoPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + for (let index = 0; index < 128; index += 1) { + const repoId = `repo-${index}` + await handlers['worktrees:listDetected'](null, { repoId }) + notifyWorktreesChanged(mainWindow as never, repoId) + } + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 0 + }) + expect(listWorktreesMock).toHaveBeenCalledTimes(128) + }) + + it('keeps a replacement scan current after an older scan settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + const result = [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[0](result) + await staleList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 1 + }) + + resolvers[1](result) + await replacementList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + + it('does not let an older scan overwrite a replacement that settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/fresh-worktree': { + worktreeId: 'repo-1::/workspace/fresh-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + const mainWorktree: GitWorktreeInfo = { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[1]([ + mainWorktree, + { + path: '/workspace/fresh-worktree', + head: 'fresh-head', + branch: 'refs/heads/fresh-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await replacementList + + resolvers[0]([ + mainWorktree, + { + path: '/workspace/stale-worktree', + head: 'stale-head', + branch: 'refs/heads/stale-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await staleList + + const cached = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/fresh-worktree' + ]) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + await expect( + resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never) + ).resolves.toBe(resolve('/workspace/fresh-worktree')) + await expect( + resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never) + ).rejects.toThrow('Access denied: unknown repository or worktree path') + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index df0dfd4c585..6aeb1144313 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -469,37 +469,59 @@ type DetectedWorktreeScanCacheEntry = { worktrees: GitWorktreeInfo[] } +type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean } const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map>() -const detectedWorktreeScanGenerations = new Map() +const detectedWorktreeScanInFlight = new Map() function invalidateDetectedWorktreeScanCache(repoId: string): void { const keyPrefix = `${repoId}\0` for (const key of new Set([ ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys(), - ...detectedWorktreeScanGenerations.keys() + ...detectedWorktreeScanInFlight.keys() ])) { if (!key.startsWith(keyPrefix)) { continue } detectedWorktreeScanCache.delete(key) - detectedWorktreeScanInFlight.delete(key) - detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token, so later scans can settle + // without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } } } registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: scans still pending across a test reset must not repopulate the + // cache afterward and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } detectedWorktreeScanCache.clear() detectedWorktreeScanInFlight.clear() - detectedWorktreeScanGenerations.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } } async function listDetectedGitWorktrees( @@ -522,24 +544,25 @@ async function listDetectedGitWorktrees( const inFlight = detectedWorktreeScanInFlight.get(cacheKey) if (inFlight) { - return { gitWorktrees: await inFlight, fresh: false } + return { gitWorktrees: await inFlight.promise, fresh: false } } - const scan = listRepoWorktrees(repo, localWorktreeGitOptions) - const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0 + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } detectedWorktreeScanInFlight.set(cacheKey, scan) try { - const gitWorktrees = await scan + const gitWorktrees = await scan.promise // Why: a create/remove notification can invalidate while the git scan is // still running. Do not let that stale scan repopulate the cache afterward. - const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation - if (isCurrentGeneration) { + if (!scan.invalidated) { detectedWorktreeScanCache.set(cacheKey, { worktrees: gitWorktrees, expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS }) } - return { gitWorktrees, fresh: isCurrentGeneration } + return { gitWorktrees, fresh: !scan.invalidated } } finally { if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { detectedWorktreeScanInFlight.delete(cacheKey) From d8a9da7351d08f6e3378aad7b4763c90ea771745 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:03:14 -0700 Subject: [PATCH 082/138] Fix PR comment selection persistence leak (#7688) Bound the PR/MR queued-comment selection cache to 1024 LRU contexts and make cache recency commit-safe so long renderer sessions cannot retain unbounded unsent selections. --- .../pr-comments-list-selection.test.tsx | 143 ++++++++++++++++-- .../pr-comments-list-selection.ts | 123 ++++++++++----- 2 files changed, 218 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index ec89aa87288..a7b9af2153f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +import { act, StrictMode, Suspense, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' @@ -46,7 +48,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' import { - clearPRCommentsListSelection, + MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS, + clearPRCommentsListSelectionsForTests, + getPRCommentsListSelectionCountForTests, + seedPRCommentsListSelectionForTests, type PRCommentsListSelectionClearRequest } from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' @@ -55,7 +60,7 @@ let container: HTMLDivElement let root: Root beforeEach(() => { - clearPRCommentsListSelection('review:42') + clearPRCommentsListSelectionsForTests() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -66,6 +71,7 @@ afterEach(() => { root.unmount() }) container.remove() + clearPRCommentsListSelectionsForTests() }) function comment(overrides: Partial): PRComment { @@ -83,20 +89,47 @@ function comment(overrides: Partial): PRComment { function renderList(props: { comments: PRComment[] contextKey?: string + strictMode?: boolean onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void clearRequest?: PRCommentsListSelectionClearRequest | null }): void { + const list = ( + + + + ) + act(() => { + root.render(props.strictMode ? {list} : list) + }) +} + +const neverSettles = new Promise(() => {}) + +function SuspendForever(): ReactNode { + throw neverSettles +} + +function renderAbandonedList(comments: PRComment[], contextKey: string): void { act(() => { root.render( - - - + Loading review}> + + + + + ) }) } @@ -303,6 +336,23 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('drops an empty selection from the persisted context cache', () => { + renderList({ + comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] + }) + clickButton('Queue for agent') + + const selectedCheckbox = container.querySelector( + 'button[role="checkbox"][aria-checked="true"]' + ) + expect(selectedCheckbox).not.toBeNull() + act(() => { + selectedCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(getPRCommentsListSelectionCountForTests()).toBe(0) + }) + it('clears sent standalone bot comments from the queue when the parent confirms launch', () => { const comments = [ comment({ @@ -378,5 +428,74 @@ describe('PRCommentsList comment resolution selection', () => { }) expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(getPRCommentsListSelectionCountForTests()).toBe(0) + }) + + it('keeps GitHub and GitLab review selections isolated under Strict Mode replay', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts' })] + const githubContext = 'local::repo::branch::github::42::github-head' + const gitlabContext = 'local::repo::branch::gitlab::42::gitlab-head' + + renderList({ comments, contextKey: githubContext, strictMode: true }) + clickButton('Queue for agent') + renderList({ comments, contextKey: gitlabContext, strictMode: true }) + clickButton('Queue for agent') + + expect(getPRCommentsListSelectionCountForTests()).toBe(2) + renderList({ comments, contextKey: githubContext, strictMode: true }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + renderList({ comments, contextKey: gitlabContext, strictMode: true }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + }) + + it('does not refresh LRU recency for an abandoned Suspense render', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts' })] + const queuedGroupIds = ['thread:thread-1'] as const + const seedContext = (contextKey: string): void => { + seedPRCommentsListSelectionForTests(contextKey, queuedGroupIds) + } + + seedContext('review:oldest') + for (let i = 0; i < MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS - 1; i += 1) { + seedContext(`review:recent-${i}`) + } + + renderAbandonedList(comments, 'review:oldest') + expect(container.textContent).toContain('Loading review') + seedContext('review:new') + + renderList({ comments, contextKey: 'review:oldest' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + renderList({ comments, contextKey: 'review:recent-0' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + }) + + it('bounds persisted review contexts while retaining recently restored selections', () => { + const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] + const queuedGroupIds = ['thread:thread-1'] as const + const seedContext = (contextKey: string): void => { + seedPRCommentsListSelectionForTests(contextKey, queuedGroupIds) + } + + seedContext('review:keep') + for (let i = 0; i < MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS - 1; i += 1) { + seedContext(`review:stale-${i}`) + } + + // Why: committed remount must refresh LRU recency for the restored context. + renderList({ comments, contextKey: 'review:keep' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + seedContext('review:new') + + expect(getPRCommentsListSelectionCountForTests()).toBe( + MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS + ) + + renderList({ comments, contextKey: 'review:stale-0' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + + renderList({ comments, contextKey: 'review:keep' }) + expect(hasButton('Send 1 queued comments to AI')).toBe(true) }) }) diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 71b51ad5b3f..6767e405c05 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -26,26 +26,53 @@ type PRCommentsListSelectionState = { } const EMPTY_SELECTED_GROUP_IDS = new Set() +// Why: queued selections need to survive sidebar remounts, but old PR/MR +// contexts can disappear without another clear signal in a long renderer run. +export const MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS = 1024 const persistedSelectionByContextKey = new Map< string, { isSelectingForAI: boolean; selectedGroupIds: Set } >() +function trimPersistedSelectionContexts(): void { + while (persistedSelectionByContextKey.size > MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS) { + const oldestContextKey = persistedSelectionByContextKey.keys().next().value + if (oldestContextKey === undefined) { + break + } + persistedSelectionByContextKey.delete(oldestContextKey) + } +} + function persistSelectionState(state: PRCommentsListSelectionState): void { if (!state.contextKey) { return } - if (!state.isSelectingForAI && state.selectedGroupIds.size === 0) { + if (state.selectedGroupIds.size === 0) { persistedSelectionByContextKey.delete(state.contextKey) return } + persistedSelectionByContextKey.delete(state.contextKey) persistedSelectionByContextKey.set(state.contextKey, { isSelectingForAI: state.isSelectingForAI, selectedGroupIds: new Set(state.selectedGroupIds) }) + trimPersistedSelectionContexts() } -function createSelectionState(contextKey: string | undefined): PRCommentsListSelectionState { +function refreshPersistedSelectionContext(contextKey: string | undefined): void { + if (!contextKey) { + return + } + const persisted = persistedSelectionByContextKey.get(contextKey) + if (!persisted) { + return + } + persistedSelectionByContextKey.delete(contextKey) + persistedSelectionByContextKey.set(contextKey, persisted) +} + +function readSelectionState(contextKey: string | undefined): PRCommentsListSelectionState { const persisted = contextKey ? persistedSelectionByContextKey.get(contextKey) : undefined return { contextKey, @@ -60,20 +87,48 @@ export function clearPRCommentsListSelection(contextKey: string | undefined): vo } } +export function clearPRCommentsListSelectionsForTests(): void { + persistedSelectionByContextKey.clear() +} + +export function getPRCommentsListSelectionCountForTests(): number { + return persistedSelectionByContextKey.size +} + +// Why: bound/LRU tests need to fill the cache without 1024 React mounts per case. +export function seedPRCommentsListSelectionForTests( + contextKey: string, + selectedGroupIds: Iterable, + isSelectingForAI = true +): void { + persistSelectionState({ + contextKey, + isSelectingForAI, + selectedGroupIds: new Set(selectedGroupIds) + }) +} + export function usePRCommentsListSelection( comments: PRComment[], selectionContextKey: string | undefined, clearRequest?: PRCommentsListSelectionClearRequest | null ): PRCommentsListSelection { const lastClearRequestTokenRef = useRef(null) - const [selectionState, setSelectionState] = useState(() => - createSelectionState(selectionContextKey) - ) + const [renderedSelectionState, setRenderedSelectionState] = + useState(() => readSelectionState(selectionContextKey)) + const selectionState = + renderedSelectionState.contextKey === selectionContextKey + ? renderedSelectionState + : readSelectionState(selectionContextKey) + const commitSelectionState = useCallback((next: PRCommentsListSelectionState): void => { + persistSelectionState(next) + setRenderedSelectionState(next) + }, []) useEffect(() => { - setSelectionState((prev) => - prev.contextKey === selectionContextKey ? prev : createSelectionState(selectionContextKey) - ) + // Why: only a committed context may affect LRU order; render can be + // abandoned or replayed by Strict Mode/Suspense. + refreshPersistedSelectionContext(selectionContextKey) }, [selectionContextKey]) useEffect(() => { @@ -90,9 +145,8 @@ export function usePRCommentsListSelection( isSelectingForAI: false, selectedGroupIds: new Set() } - persistSelectionState(next) - setSelectionState(next) - }, [clearRequest, selectionContextKey]) + commitSelectionState(next) + }, [clearRequest, commitSelectionState, selectionContextKey]) // Why: selectable groups come from the unfiltered list so switching the // audience filter doesn't silently drop already-selected comments. @@ -138,10 +192,10 @@ export function usePRCommentsListSelection( isSelectingForAI: selectionState.isSelectingForAI, selectedGroupIds: new Set(selectedGroupIds) } - persistSelectionState(next) - setSelectionState(next) + commitSelectionState(next) }, [ candidateSelectedGroupIds, + commitSelectionState, comments.length, isCurrentSelectionContext, selectedGroupIds, @@ -169,10 +223,9 @@ export function usePRCommentsListSelection( isSelectingForAI: true, selectedGroupIds: new Set([groupId]) } - persistSelectionState(next) - setSelectionState(next) + commitSelectionState(next) }, - [selectableGroupsById, selectionContextKey] + [commitSelectionState, selectableGroupsById, selectionContextKey] ) const clearSelection = useCallback((): void => { @@ -181,34 +234,32 @@ export function usePRCommentsListSelection( isSelectingForAI: false, selectedGroupIds: new Set() } - persistSelectionState(next) - setSelectionState(next) - }, [selectionContextKey]) + commitSelectionState(next) + }, [commitSelectionState, selectionContextKey]) const toggleGroupSelection = useCallback( (groupId: string, checked: boolean): void => { if (!selectableGroupsById.has(groupId)) { return } - setSelectionState((prev) => { - const base = - prev.contextKey === selectionContextKey ? prev.selectedGroupIds : EMPTY_SELECTED_GROUP_IDS - const next = new Set([...base].filter((id) => selectableGroupsById.has(id))) - if (checked) { - next.add(groupId) - } else { - next.delete(groupId) - } - const nextState = { - contextKey: selectionContextKey, - isSelectingForAI: true, - selectedGroupIds: next - } - persistSelectionState(nextState) - return nextState + const current = readSelectionState(selectionContextKey) + const base = + current.contextKey === selectionContextKey + ? current.selectedGroupIds + : EMPTY_SELECTED_GROUP_IDS + const next = new Set([...base].filter((id) => selectableGroupsById.has(id))) + if (checked) { + next.add(groupId) + } else { + next.delete(groupId) + } + commitSelectionState({ + contextKey: selectionContextKey, + isSelectingForAI: true, + selectedGroupIds: next }) }, - [selectableGroupsById, selectionContextKey] + [commitSelectionState, selectableGroupsById, selectionContextKey] ) return { From 3804f75f6a2862cf064625610def9ab515704e03 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:03:49 -0700 Subject: [PATCH 083/138] fix native chat cleanup registration leak (#7663) --- .../native-chat-subscribe-lifecycle.test.ts | 228 ++++++++++++++++++ src/main/ipc/native-chat.test.ts | 62 +++++ src/main/ipc/native-chat.ts | 89 +++++-- 3 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 src/main/ipc/native-chat-subscribe-lifecycle.test.ts diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..53588814848 --- /dev/null +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + subscribeTranscript: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +vi.mock('../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: subscribeTranscript +})) + +import { + _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatSenderCleanupCountForTest, + clearNativeChatSubscriptions, + registerNativeChatHandlers +} from './native-chat' + +type TestSubscription = { + unsubscribe: ReturnType +} + +type DeferredSubscription = { + promise: Promise + reject: (error: Error) => void + resolve: () => void + unsubscribe: ReturnType +} + +type SenderHarness = { + destroy: () => void + registeredCleanupCount: () => number + sender: { + id: number + isDestroyed: () => boolean + once: (event: string, callback: () => void) => void + send: ReturnType + } +} + +beforeEach(() => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + subscribeTranscript.mockReset() + registerNativeChatHandlers() +}) + +function deferredSubscription(): DeferredSubscription { + const unsubscribe = vi.fn() + let resolvePromise: (subscription: TestSubscription) => void = () => {} + let rejectPromise: (error: Error) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + reject: rejectPromise, + resolve: () => resolvePromise({ unsubscribe }), + unsubscribe + } +} + +function createSender(id: number): SenderHarness { + let destroyed = false + const destroyedCallbacks: (() => void)[] = [] + return { + destroy: () => { + destroyed = true + for (const callback of destroyedCallbacks) { + callback() + } + }, + registeredCleanupCount: () => destroyedCallbacks.length, + sender: { + id, + isDestroyed: () => destroyed, + once: (event, callback) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }, + send: vi.fn() + } + } +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:subscribe') + if (!listener) { + throw new Error('subscribe listener not registered') + } + listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) +} + +function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:unsubscribe') + if (!listener) { + throw new Error('unsubscribe listener not registered') + } + listener({ sender }, { subscriptionId }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('timed out waiting for lifecycle state') + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('nativeChat subscribe lifecycle', () => { + it('closes a watcher that resolves after renderer unsubscribe', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(1) + + subscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + unsubscribe(renderer.sender, 'unmount') + expect(pending.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('closes a watcher that resolves after renderer destruction', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(2) + + subscribe(renderer.sender, 'destroy') + renderer.destroy() + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + }) + + it('keeps the latest same-id subscribe when setup resolves in reverse order', async () => { + const older = deferredSubscription() + const newer = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + const renderer = createSender(3) + + subscribe(renderer.sender, 'same-id') + subscribe(renderer.sender, 'same-id') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + + newer.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(newer.unsubscribe).not.toHaveBeenCalled() + older.resolve() + await waitFor(() => older.unsubscribe.mock.calls.length === 1) + + unsubscribe(renderer.sender, 'same-id') + expect(newer.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + }) + + it('clears rejected setup without duplicating sender cleanup registration', async () => { + const failed = deferredSubscription() + const retry = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(failed.promise).mockReturnValueOnce(retry.promise) + const renderer = createSender(4) + + subscribe(renderer.sender, 'retry') + failed.reject(new Error('watch setup failed')) + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) + + subscribe(renderer.sender, 'retry') + expect(renderer.registeredCleanupCount()).toBe(1) + retry.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + unsubscribe(renderer.sender, 'retry') + expect(retry.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('isolates late setup from a replacement renderer reusing the sender id', async () => { + const oldPending = deferredSubscription() + const replacementPending = deferredSubscription() + subscribeTranscript + .mockReturnValueOnce(oldPending.promise) + .mockReturnValueOnce(replacementPending.promise) + const oldRenderer = createSender(41) + const replacementRenderer = createSender(41) + + subscribe(oldRenderer.sender, 'remount') + oldRenderer.destroy() + subscribe(replacementRenderer.sender, 'remount') + expect(replacementRenderer.registeredCleanupCount()).toBe(1) + + replacementPending.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + oldPending.resolve() + await waitFor(() => oldPending.unsubscribe.mock.calls.length === 1) + expect(replacementPending.unsubscribe).not.toHaveBeenCalled() + + replacementRenderer.destroy() + expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) +}) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index fd09a14ff3f..d16a39b1f13 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + _getNativeChatSenderCleanupCountForTest, clearNativeChatSubscriptions, clearNativeChatTranscriptCache, registerNativeChatHandlers @@ -238,6 +239,67 @@ describe('nativeChat:readSession handler', () => { } }) + it('drops cleanup registration when sender is destroyed before subscribe completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-race.jsonl'), + `${jsonLines([ + { + type: 'user', + uuid: 'u-race', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Race' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + let destroyed = false + let destroyedCb: (() => void) | undefined + const sender = { + id: 41, + isDestroyed: () => destroyed, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: vi.fn() + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-race', + agent: 'claude', + sessionId: 'sess-race' + } + ) + + expect(destroyedCb).toBeDefined() + destroyed = true + destroyedCb!() + + await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0) + expect(sender.send).not.toHaveBeenCalled() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('returns an error for an unknown session without throwing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index ad6117db0c1..3ed4e9b74a6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -71,9 +71,17 @@ type LiveSubscription = { // same renderer can watch several panes, and a destroyed window tears down all // of its watchers — strict teardown to avoid fd leaks (plan U4 risk). const liveSubscriptions = new Map>() +// Why: unsubscribe and renderer destruction must invalidate async watcher setup +// before it can publish a late subscription into the live map. +const pendingSubscriptions = new Map>() const senderCleanupRegistered = new Set() function teardownSubscription(senderId: number, subscriptionId: string): void { + const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.delete(subscriptionId) + if (pendingBySubId?.size === 0) { + pendingSubscriptions.delete(senderId) + } const bySubId = liveSubscriptions.get(senderId) const live = bySubId?.get(subscriptionId) if (!live || !bySubId) { @@ -87,6 +95,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { } function teardownAllForSender(senderId: number): void { + // The destroyed event can arrive before async subscription setup stores a watcher. + senderCleanupRegistered.delete(senderId) + pendingSubscriptions.delete(senderId) const bySubId = liveSubscriptions.get(senderId) if (!bySubId) { return @@ -95,7 +106,6 @@ function teardownAllForSender(senderId: number): void { live.subscription.unsubscribe() } liveSubscriptions.delete(senderId) - senderCleanupRegistered.delete(senderId) } function registerSenderCleanup(sender: WebContents): void { @@ -107,6 +117,27 @@ function registerSenderCleanup(sender: WebContents): void { sender.once('destroyed', () => teardownAllForSender(sender.id)) } +function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { + teardownSubscription(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, token) + pendingSubscriptions.set(senderId, bySubId) + return token +} + +function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubId = pendingSubscriptions.get(senderId) + if (bySubId?.get(subscriptionId) !== token) { + return false + } + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + pendingSubscriptions.delete(senderId) + } + return true +} + async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender if (sender.isDestroyed()) { @@ -114,26 +145,32 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } const { subscriptionId, agent, sessionId, transcriptPath } = args // Replace any prior subscription under the same id (session change/resubscribe). - teardownSubscription(sender.id, subscriptionId) + const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) - const subscription = await subscribeNativeChatTranscript({ - agent, - sessionId, - transcriptPath, - onAppend: (messages) => { - if (sender.isDestroyed()) { - return + let subscription: NativeChatTranscriptSubscription + try { + subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } - sender.send('nativeChat:appended', payload) - } - }) + }) + } catch { + takePendingSubscription(sender.id, subscriptionId, pendingToken) + return + } - // The window may have gone away (or the subscription been replaced) while we - // resolved the file path — don't register a now-orphaned watcher. - const stillCurrent = !sender.isDestroyed() - if (!stillCurrent) { + // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup + // while path resolution is pending; only the owning token may publish its watcher. + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } @@ -147,12 +184,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg liveSubscriptions.set(sender.id, bySubId) } -/** Test-only: drop all live transcript subscriptions between runs. */ +/** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = Array.from(liveSubscriptions.keys()) + const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) for (const senderId of senderIds) { teardownAllForSender(senderId) } + pendingSubscriptions.clear() + senderCleanupRegistered.clear() +} + +export function _getNativeChatSenderCleanupCountForTest(): number { + return senderCleanupRegistered.size +} + +export function _getNativeChatPendingSubscriptionCountForTest(): number { + let count = 0 + for (const bySubId of pendingSubscriptions.values()) { + count += bySubId.size + } + return count } export function registerNativeChatHandlers(): void { From b18ec39e1f337b3771cb5ac10416e8487731f1d5 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sat, 11 Jul 2026 14:14:12 -0700 Subject: [PATCH 084/138] fix(naming): lead workspace and tab names with the work-item identifier (#8238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(naming): lead workspace and tab names with the work-item identifier Auto-generated workspace names, branch-rename display names, and tab titles now lead with the referenced PR/MR/issue/ticket — e.g. `PR 1033 - Review` instead of a paraphrase like "Review community pr 1094" that buried or dropped the number. Identifiers are the highest- signal, most searchable token, so leading with them makes the sidebar and tabs scannable and disambiguates same-verb work items. - New shared `work-item-reference.ts` extracts the identifier from the raw prompt: URLs are validated by path structure (owner/repo/pull/N, GitLab's `/-/` marker) so GitHub Enterprise / self-hosted GitLab still resolve while stray `/pull/` paths (CDN, docs) do not; a ticket- prefix denylist keeps `SHA-256` / `UTF-8` / `ISO-8601` from being read as Jira/Linear keys. - Reconciles the existing create-from-work-item naming (was action-first `Review PR 1033`) with the first-work auto-rename onto one identifier- first format via a shared `formatIdentifierFirst`, so the two paths can't drift. - Fixes a pre-existing tab-title bug where markdown punctuation was stripped before URLs, splitting a GitLab `merge_requests` URL at its underscore and leaking "requests" into the title. * fix(naming): keep emphasis-wrapped URLs intact in generated names The URL/markdown strip reorder left the tab-title URL strip anchored on \b, which fails when a URL is wrapped in markdown emphasis (_...pull/5_) and leaked URL fragments into the title. Drop the \b anchor and trim trailing markdown emphasis (*_~) in the URL identifier parser, keeping interior underscores (merge_requests) intact. Adds regression tests. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../agent-hooks/first-work-branch-rename.ts | 6 +- .../src/lib/launch-work-item-direct.test.ts | 8 +- src/shared/agent-tab-title.test.ts | 42 ++++- src/shared/agent-tab-title.ts | 25 ++- src/shared/display-name-from-work.test.ts | 60 ++++++ src/shared/display-name-from-work.ts | 76 ++++++++ src/shared/work-item-reference.test.ts | 102 ++++++++++ src/shared/work-item-reference.ts | 174 ++++++++++++++++++ src/shared/workspace-name.test.ts | 12 +- src/shared/workspace-name.ts | 6 +- 10 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 src/shared/display-name-from-work.test.ts create mode 100644 src/shared/display-name-from-work.ts create mode 100644 src/shared/work-item-reference.test.ts create mode 100644 src/shared/work-item-reference.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index fabf508b141..2b734fd41ca 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -8,10 +8,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree- import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { - humanizeBranchSlug, isAutoGeneratedCreatureBranchName, stripConfiguredBranchPrefix } from '../../shared/branch-name-from-work' +import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' @@ -307,7 +307,7 @@ async function runAutoRename( // Keep the sidebar name in sync with the branch — but only when it is still // the auto-generated creature name, so a name the user typed is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) - const newDisplayName = humanizeBranchSlug(newBranchLeaf) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) if (updateDisplay) { deps.setDisplayName(worktreeId, newDisplayName) @@ -390,7 +390,7 @@ async function runFolderWorkspaceTitleAutoRename( return retry(`generation failed: ${generated.error}`) } - const newDisplayName = humanizeBranchSlug(generated.slug) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) deps.setDisplayName(worktreeId, newDisplayName) deps.setRenameError(worktreeId, null) deps.onRenamed(worktreeId) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 77d8b44e062..04da6b1d341 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -77,11 +77,11 @@ vi.mock('@/lib/new-workspace', () => ({ ? { displayName: args.workItem.type === 'pr' - ? `Review PR ${args.workItem.number}` + ? `PR ${args.workItem.number} - Review` : `Issue ${args.workItem.number}`, seedName: args.workItem.type === 'pr' - ? `review-pr-${args.workItem.number}` + ? `pr-${args.workItem.number}-review` : `issue-${args.workItem.number}` } : null, @@ -253,12 +253,12 @@ describe('launchWorkItemDirect', () => { }) expect(mocks.createWorktree).toHaveBeenCalledWith( 'repo-1', - 'review-pr-6934', + 'pr-6934-review', 'abc123', 'inherit', undefined, 'sidebar', - 'Review PR 6934', + 'PR 6934 - Review', undefined, 6934, { remoteName: 'origin', branchName: 'feature/fix' }, diff --git a/src/shared/agent-tab-title.test.ts b/src/shared/agent-tab-title.test.ts index c931052201d..6ee20010459 100644 --- a/src/shared/agent-tab-title.test.ts +++ b/src/shared/agent-tab-title.test.ts @@ -28,9 +28,47 @@ describe('deriveGeneratedTabTitle', () => { ) }) - it('keeps useful text after common issue prefixes', () => { + it('leads with the issue identifier and keeps the description after it', () => { expect(deriveGeneratedTabTitle('Issue #2056: Opt-in generated tab titles for agents')).toBe( - 'Opt in generated tab titles for agents' + 'Issue 2056 - Opt in generated tab' + ) + }) + + it('recovers a PR number from a URL the pipeline strips, leading with it', () => { + expect( + deriveGeneratedTabTitle( + 'Review this community PR https://github.com/EveryInc/plugin/pull/1094' + ) + ).toBe('PR 1094 - Review this community') + }) + + it('leads with a GitLab MR identifier', () => { + expect( + deriveGeneratedTabTitle('fix https://gitlab.com/group/app/-/merge_requests/42 quickly') + ).toBe('MR 42 - Fix quickly') + }) + + it('strips a URL containing underscores intact', () => { + expect( + deriveGeneratedTabTitle('inspect https://gitlab.com/g/p/-/work_items/9 then report') + ).toBe('Issue 9 - Inspect then report') + }) + + it('leads with the identifier for a URL wrapped in markdown emphasis, without leaking fragments', () => { + const title = deriveGeneratedTabTitle('Review _https://github.com/o/r/pull/5_ now') + expect(title).toBe('PR 5 - Review now') + expect(title).not.toMatch(/https|pull/) + }) + + it('leads with a bare ticket key', () => { + expect(deriveGeneratedTabTitle('implement ENG-456 login flow')).toBe( + 'ENG-456 - Implement login flow' + ) + }) + + it('does not treat a cipher token as an identifier', () => { + expect(deriveGeneratedTabTitle('implement SHA-256 hashing in the signer')).toBe( + 'Implement SHA 256 hashing in the signer' ) }) diff --git a/src/shared/agent-tab-title.ts b/src/shared/agent-tab-title.ts index 9ff08dcce71..2060409fbd7 100644 --- a/src/shared/agent-tab-title.ts +++ b/src/shared/agent-tab-title.ts @@ -1,3 +1,9 @@ +import { + extractWorkIdentifier, + formatIdentifierFirst, + stripWorkIdentifierEcho +} from './work-item-reference' + export const GENERATED_TAB_TITLE_MAX_LENGTH = 40 export const GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT = 512 @@ -74,9 +80,14 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { const promptPreview = prompt.slice(0, GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT) const firstClause = promptPreview .trim() + // Strip URLs before markdown punctuation: a GitLab URL like + // `/merge_requests/42` contains `_`, and folding that to a space first would + // split the URL and leak fragments ("requests") into the title. No `\b` + // anchor: a URL wrapped in markdown emphasis (`_https://…_`) is preceded by + // a word char, where `\bhttps` would fail to match and leak the whole URL. + .replace(/https?:\/\/\S+/gi, ' ') .replace(/[`*_~#>[\]{}()]/g, ' ') .replace(/^(?:issue|task|bug|feature|pr)\s*(?:#?\d+)?\s*[:-]\s*/i, '') - .replace(/\bhttps?:\/\/\S+/gi, ' ') .split(/[.!?;\n\r\u2028\u2029]/u)[0] ?.trim() @@ -98,6 +109,18 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { candidate = foldGeneratedTabTitleWhitespace(candidate.replace(/[^\p{L}\p{N}\s]/gu, ' ')) + // Lead with the review target (`PR 1094 - …`) so the tab matches the sidebar + // workspace name; the pipeline above strips the URL/prefix, so the number is + // recovered from the raw prompt rather than the cleaned clause. + const identifier = extractWorkIdentifier(promptPreview) + if (identifier) { + const detail = capitalizeFirstLetter(stripWorkIdentifierEcho(candidate, identifier)) + return truncateAtWordBoundary( + formatIdentifierFirst(identifier.label, detail), + GENERATED_TAB_TITLE_MAX_LENGTH + ) + } + if (!candidate) { return null } diff --git a/src/shared/display-name-from-work.test.ts b/src/shared/display-name-from-work.test.ts new file mode 100644 index 00000000000..ea467e3ea3b --- /dev/null +++ b/src/shared/display-name-from-work.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkspaceDisplayName } from './display-name-from-work' + +// Identifier extraction itself is covered in work-item-reference.test.ts; these +// cases exercise the end-to-end display-name composition. +describe('deriveWorkspaceDisplayName', () => { + it('leads with the identifier and a single action verb', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'Carefully evaluate https://github.com/o/r/pull/1033. Fix the merge conflict.', + slug: 'review-community-pr-conflict' + }) + ).toBe('PR 1033 - Review') + }) + + it('drops identifier tokens the slug also carried', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'look at this community PR https://github.com/o/r/pull/1094', + slug: 'review-community-pr-1094' + }) + ).toBe('PR 1094 - Review') + }) + + it('uses a namespaced ticket id bare, without a type prefix', () => { + expect( + deriveWorkspaceDisplayName({ prompt: 'fix ENG-456 crash', slug: 'fix-eng-456-crash' }) + ).toBe('ENG-456 - Fix') + }) + + it('returns the identifier alone when no action word survives', () => { + expect(deriveWorkspaceDisplayName({ prompt: 'PR 12', slug: 'pr-12' })).toBe('PR 12') + }) + + it('carries a collision suffix so same-target worktrees stay distinct', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'review https://github.com/o/r/pull/1033', + slug: 'review-conflict', + resolvedLeaf: 'review-conflict-2' + }) + ).toBe('PR 1033 - Review (2)') + }) + + it('falls back to the humanized leaf when no identifier is present', () => { + expect( + deriveWorkspaceDisplayName({ prompt: 'add a dark mode toggle', slug: 'add-dark-mode-toggle' }) + ).toBe('Add dark mode toggle') + }) + + it('humanizes the resolved leaf (with suffix) on the fallback path', () => { + expect( + deriveWorkspaceDisplayName({ + prompt: 'add a logout button', + slug: 'add-logout-button', + resolvedLeaf: 'add-logout-button-2' + }) + ).toBe('Add logout button 2') + }) +}) diff --git a/src/shared/display-name-from-work.ts b/src/shared/display-name-from-work.ts new file mode 100644 index 00000000000..65889b8417c --- /dev/null +++ b/src/shared/display-name-from-work.ts @@ -0,0 +1,76 @@ +import { humanizeBranchSlug } from './branch-name-from-work' +import { extractWorkIdentifier, formatIdentifierFirst } from './work-item-reference' + +// Why: the git branch must stay lowercase kebab-case, but the sidebar display +// name is free-form. When the first prompt references a review target, that +// identifier is the highest-signal, most searchable token — so we lead with it +// (`PR 1033 - Review`) instead of a paraphrased slug like "Review community pr +// 1033". The identifier comes from the raw prompt (via work-item-reference), +// where the number still exists, not from the lossy branch slug. + +// Type words that are never a useful action verb, skipped even if a slug leads +// with one (e.g. `pr-1094-review` → `Review`, not `Pr`). +const ACTION_STOPWORDS = new Set([ + 'pr', + 'mr', + 'pull', + 'merge', + 'request', + 'requests', + 'issue', + 'issues', + 'the', + 'a', + 'an', + 'to', + 'for', + 'of' +]) + +// The first slug word that is neither a digit nor an identifier/type token, +// capitalized — the task verb ("Review", "Fix"). '' when none remains. +function leadingActionWord(slug: string, identifierTokens: string[]): string { + const skip = new Set([...identifierTokens, ...ACTION_STOPWORDS]) + for (const word of slug.split('-').filter(Boolean)) { + if (/^\d+$/.test(word) || skip.has(word)) { + continue + } + return word.charAt(0).toUpperCase() + word.slice(1) + } + return '' +} + +// A `-N` collision suffix (`fix-auth` → `fix-auth-2`) that resolveUniqueBranchName +// appended, so two worktrees on the same identifier stay distinguishable. +function collisionSuffixFromLeaf( + baseSlug: string, + resolvedLeaf: string | undefined +): number | null { + if (!resolvedLeaf || resolvedLeaf === baseSlug || !resolvedLeaf.startsWith(`${baseSlug}-`)) { + return null + } + const rest = resolvedLeaf.slice(baseSlug.length + 1) + return /^\d+$/.test(rest) ? Number(rest) : null +} + +/** + * Build the sidebar display name for a freshly auto-renamed workspace. When the + * prompt names a review target, returns ` - ` (e.g. + * `PR 1033 - Review`); otherwise falls back to the plain humanized branch slug. + * `resolvedLeaf` is the final branch leaf (after any collision suffix); omit it + * for folder workspaces that have no git branch. + */ +export function deriveWorkspaceDisplayName(input: { + prompt: string + slug: string + resolvedLeaf?: string +}): string { + const identifier = extractWorkIdentifier(input.prompt) + if (!identifier) { + return humanizeBranchSlug(input.resolvedLeaf ?? input.slug) + } + const action = leadingActionWord(input.slug, identifier.tokens) + const base = formatIdentifierFirst(identifier.label, action) + const suffix = collisionSuffixFromLeaf(input.slug, input.resolvedLeaf) + return suffix ? `${base} (${suffix})` : base +} diff --git a/src/shared/work-item-reference.test.ts b/src/shared/work-item-reference.test.ts new file mode 100644 index 00000000000..c49d9f87812 --- /dev/null +++ b/src/shared/work-item-reference.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { extractWorkIdentifier, stripWorkIdentifierEcho } from './work-item-reference' + +describe('extractWorkIdentifier', () => { + it('reads a GitHub pull request URL', () => { + expect(extractWorkIdentifier('Review https://github.com/EveryInc/plugin/pull/1033')).toEqual({ + label: 'PR 1033', + tokens: ['pr', '1033'] + }) + }) + + it('reads a Bitbucket pull-requests URL', () => { + expect( + extractWorkIdentifier('Look at https://bitbucket.org/team/repo/pull-requests/77')?.label + ).toBe('PR 77') + }) + + it('reads a GitLab merge request URL as MR, and a work_items URL as an issue', () => { + expect(extractWorkIdentifier('Check https://gitlab.com/group/app/-/merge_requests/42')).toEqual( + { label: 'MR 42', tokens: ['mr', '42'] } + ) + expect(extractWorkIdentifier('https://gitlab.example.com/g/p/-/work_items/9')?.label).toBe( + 'Issue 9' + ) + }) + + it('reads an issue URL', () => { + expect(extractWorkIdentifier('Fix https://github.com/o/r/issues/88')?.label).toBe('Issue 88') + }) + + it('ignores URLs whose path only resembles a work item (no owner/repo, wrong host shape)', () => { + // A CDN asset path contains `/pull/2023` but is not a pull request. + expect( + extractWorkIdentifier('Load https://cdn.vendor.com/assets/pull/2023/data.json') + ).toBeNull() + // No trailing number after the item segment. + expect(extractWorkIdentifier('see https://github.com/o/r/pull/notanumber')).toBeNull() + }) + + it('tolerates trailing punctuation around a URL', () => { + expect(extractWorkIdentifier('(see https://github.com/o/r/pull/5).')?.label).toBe('PR 5') + }) + + it('reads a URL wrapped in markdown emphasis (trailing underscore/asterisk)', () => { + expect(extractWorkIdentifier('Review _https://github.com/o/r/pull/5_ now')?.label).toBe('PR 5') + expect(extractWorkIdentifier('Review **https://github.com/o/r/pull/1094**')?.label).toBe( + 'PR 1094' + ) + }) + + it('reads textual references', () => { + expect(extractWorkIdentifier('please review PR #1094')?.label).toBe('PR 1094') + expect(extractWorkIdentifier('triage pull request 500')?.label).toBe('PR 500') + expect(extractWorkIdentifier('reproduce issue 12')?.label).toBe('Issue 12') + expect(extractWorkIdentifier('handle merge request !9')?.label).toBe('MR 9') + }) + + it('reads a namespaced ticket id bare', () => { + expect(extractWorkIdentifier('implement ENG-456 login flow')).toEqual({ + label: 'ENG-456', + tokens: ['eng', '456'] + }) + }) + + it('does not treat standards, ciphers, or encodings as tickets', () => { + expect(extractWorkIdentifier('implement SHA-256 hashing')).toBeNull() + expect(extractWorkIdentifier('parse UTF-8 input')).toBeNull() + expect(extractWorkIdentifier('handle ISO-8601 dates')).toBeNull() + }) + + it('skips a denylisted prefix but still finds a real key after it', () => { + expect(extractWorkIdentifier('encrypt with AES-256 for ticket ENG-99')?.label).toBe('ENG-99') + }) + + it('prefers a provider URL over an incidental ticket-shaped token', () => { + expect( + extractWorkIdentifier('per RFC-2616 notes, review https://github.com/o/r/pull/7')?.label + ).toBe('PR 7') + }) + + it('falls back to a bare number, then to null', () => { + expect(extractWorkIdentifier('look at #321 when free')?.label).toBe('#321') + expect(extractWorkIdentifier('add a dark mode toggle to settings')).toBeNull() + }) +}) + +describe('stripWorkIdentifierEcho', () => { + it('removes the identifier tokens from a description', () => { + expect( + stripWorkIdentifierEcho('Review this community PR', { + label: 'PR 1094', + tokens: ['pr', '1094'] + }) + ).toBe('Review this community') + }) + + it('removes a ticket key echoed in the description', () => { + expect( + stripWorkIdentifierEcho('Fix ENG 456 crash', { label: 'ENG-456', tokens: ['eng', '456'] }) + ).toBe('Fix crash') + }) +}) diff --git a/src/shared/work-item-reference.ts b/src/shared/work-item-reference.ts new file mode 100644 index 00000000000..8fcf7e08725 --- /dev/null +++ b/src/shared/work-item-reference.ts @@ -0,0 +1,174 @@ +// Why: a single, host-aware parser for the review target named in a prompt (a +// PR, MR, issue, or ticket), shared by the sidebar workspace name and the tab +// title so both surface the same identifier. URLs are validated by *path +// structure* (owner/repo/pull/N, GitLab's `/-/` marker) rather than hostname, +// which keeps GitHub Enterprise and self-hosted GitLab working while rejecting +// stray URLs that merely contain `/pull/` (CDN assets, docs pages). + +export type WorkIdentifier = { + /** Human label, identifier-first, e.g. `PR 1033`, `MR 42`, `ENG-456`. */ + label: string + /** Lowercased identifier tokens, so consumers can drop them from a slug or + * description rather than echoing `Pr`, a bare number, or the ticket twice. */ + tokens: string[] +} + +// Prompts can be paste-sized, and a review target is named up front — so bound +// the scan to a prefix rather than running regexes over the whole prompt. +const IDENTIFIER_SCAN_LIMIT = 4096 + +// Uppercase prefixes that look like Jira/Linear keys but are standards, ciphers, +// or encodings — kept off the ticket path so `SHA-256` / `UTF-8` / `ISO-8601` +// don't become workspace names. Single-letter prefixes (`P-256`) can't match the +// two-letter-minimum pattern, so they need no entry here. +const NON_TICKET_PREFIXES = new Set([ + 'UTF', + 'SHA', + 'MD', + 'ISO', + 'RFC', + 'AES', + 'RSA', + 'EC', + 'ES', + 'RS', + 'HS', + 'PS', + 'GPT', + 'MPEG', + 'UTC', + 'GMT', + 'IPV', + 'IEEE', + 'ANSI', + 'ASCII', + 'TLS', + 'SSL', + 'HTTP', + 'HTTPS' +]) + +const URL_IN_TEXT = /https?:\/\/[^\s<>()[\]"']+/gi +// GitLab's project-internal `/-/` marker is unambiguous; a GitLab issue path +// also would not match the GitHub pattern, so ordering GitLab first is safe. +const GITLAB_ITEM_PATH = /\/-\/(issues|work_items|merge_requests)\/(\d+)(?:[/?#]|$)/i +const GITHUB_ITEM_PATH = /^\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)(?:[/?#]|$)/i +const BITBUCKET_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i + +function taggedIdentifier(type: 'PR' | 'MR' | 'Issue', num: string): WorkIdentifier { + return { label: `${type} ${num}`, tokens: [type.toLowerCase(), num] } +} + +function urlToIdentifier(raw: string): WorkIdentifier | null { + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + const path = url.pathname + const gitlab = GITLAB_ITEM_PATH.exec(path) + if (gitlab) { + return gitlab[1].toLowerCase() === 'merge_requests' + ? taggedIdentifier('MR', gitlab[2]) + : taggedIdentifier('Issue', gitlab[2]) + } + const github = GITHUB_ITEM_PATH.exec(path) + if (github) { + return github[1].toLowerCase() === 'pull' + ? taggedIdentifier('PR', github[2]) + : taggedIdentifier('Issue', github[2]) + } + const bitbucket = BITBUCKET_ITEM_PATH.exec(path) + if (bitbucket) { + return taggedIdentifier('PR', bitbucket[1]) + } + return null +} + +function findUrlIdentifier(text: string): WorkIdentifier | null { + const urls = text.match(URL_IN_TEXT) + if (!urls) { + return null + } + for (const raw of urls) { + // Trim trailing sentence punctuation and markdown emphasis (`_`/`*`/`~`): a + // URL wrapped like `_…/pull/5_` otherwise keeps the `_`, breaking the path + // anchor so the identifier is lost. Interior `_` (`merge_requests`) is kept. + const identifier = urlToIdentifier(raw.replace(/[.,;:!?*_~]+$/, '')) + if (identifier) { + return identifier + } + } + return null +} + +/** + * Pull the review-target identifier out of raw prompt text. Precedence runs from + * most reliable (provider URLs) to least (a bare `#123`), so a real URL wins over + * an incidental ticket-shaped token. Returns null when the prompt names none. + */ +export function extractWorkIdentifier(text: string): WorkIdentifier | null { + const scanned = text.slice(0, IDENTIFIER_SCAN_LIMIT) + + const urlIdentifier = findUrlIdentifier(scanned) + if (urlIdentifier) { + return urlIdentifier + } + + // Textual references ("pull request #12", "PR 12", "issue 88"). + let match = scanned.match(/\bmerge\s+request\s*[#!]?\s*(\d+)/i) + if (match) { + return taggedIdentifier('MR', match[1]) + } + match = scanned.match(/\bpull\s+request\s*#?\s*(\d+)/i) ?? scanned.match(/\bpr\s*#?\s*(\d+)/i) + if (match) { + return taggedIdentifier('PR', match[1]) + } + match = scanned.match(/\bissue\s*#?\s*(\d+)/i) + if (match) { + return taggedIdentifier('Issue', match[1]) + } + + // Namespaced ticket id (Jira/Linear), used bare. Uppercase-only so lowercase + // tokens like `gpt-4` don't match; skip standards/cipher prefixes, and keep + // scanning so a real key after one (e.g. `SHA-256 … ENG-456`) still resolves. + for (const ticket of scanned.matchAll(/\b([A-Z]{2,10})-(\d{1,7})\b/g)) { + if (!NON_TICKET_PREFIXES.has(ticket[1])) { + return { label: `${ticket[1]}-${ticket[2]}`, tokens: [ticket[1].toLowerCase(), ticket[2]] } + } + } + + // Bare `#123` as a last resort — identifier-first but provider-agnostic. + match = scanned.match(/(?:^|\s)#(\d+)\b/) + if (match) { + return { label: `#${match[1]}`, tokens: [match[1]] } + } + + return null +} + +/** + * Compose an identifier-first label — `PR 1033 - Review`, or just `PR 1033` when + * there is no trailing detail. The single source of the format shared by the + * sidebar name, tab title, and auto-rename name so they cannot drift apart. + */ +export function formatIdentifierFirst(label: string, detail: string): string { + return detail ? `${label} - ${detail}` : label +} + +/** + * Remove the identifier's own tokens from a description so a caller can prepend + * the label without echoing it — `PR 1094 - Review this PR` becomes + * `PR 1094 - Review this`. + */ +export function stripWorkIdentifierEcho(text: string, identifier: WorkIdentifier): string { + let stripped = text + for (const token of identifier.tokens) { + stripped = stripped.replace(new RegExp(`\\b${token}\\b`, 'gi'), ' ') + } + return stripped.replace(/\s+/g, ' ').trim() +} diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts index 10885fbf197..797d507d83c 100644 --- a/src/shared/workspace-name.test.ts +++ b/src/shared/workspace-name.test.ts @@ -95,8 +95,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Fix Issue 2635', - seedName: 'fix-issue-2635' + displayName: 'Issue 2635 - Fix', + seedName: 'issue-2635-fix' }) }) @@ -111,8 +111,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Review PR 1234', - seedName: 'review-pr-1234' + displayName: 'PR 1234 - Review', + seedName: 'pr-1234-review' }) expect( getWorkspaceIntentName({ @@ -125,8 +125,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Fix MR 77', - seedName: 'fix-mr-77' + displayName: 'MR 77 - Fix', + seedName: 'mr-77-fix' }) }) diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts index 7ba7629e350..f98958160b9 100644 --- a/src/shared/workspace-name.ts +++ b/src/shared/workspace-name.ts @@ -2,6 +2,7 @@ import { collectCompactWorkspaceWords, foldWorkspaceNameWhitespaceToHyphen } from './workspace-name-text-scanner' +import { formatIdentifierFirst } from './work-item-reference' function normalizeApostrophes(input: string): string { return input.replace(/[‘’]/g, "'") @@ -214,7 +215,10 @@ export function getWorkspaceIntentName(args: { const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item) const identity = workItemIdentity(item) if (action) { - displayName = `${action} ${identity}` + // Identifier-first so the sidebar leads with the searchable token + // (`PR 1033 - Review`); the shared formatIdentifierFirst keeps first-create, + // auto-rename, and tab-title names on one format. + displayName = formatIdentifierFirst(identity, action) } else { const subject = compactWorkItemTitle(item.title, item) displayName = [identity, subject].filter(Boolean).join(' ') From 800967278e61e29a75ead246aa245768ea1ba736 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:24:39 -0700 Subject: [PATCH 085/138] feat: promote APFS shared paths (#8318) --- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/ipc/worktree-remote.ts | 8 +-- src/main/runtime/orca-runtime.ts | 7 +-- .../components/settings/ExperimentalPane.tsx | 54 ------------------ .../components/settings/RepositoryPane.tsx | 2 - .../settings/experimental-search.ts | 55 ------------------- src/shared/constants.ts | 1 - src/shared/telemetry-events.ts | 1 - src/shared/types.ts | 15 +---- 10 files changed, 7 insertions(+), 138 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9958e0fe867..d3c39b0b067 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -142,7 +142,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a7cf0d033e2..8c4779573fc 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -147,7 +147,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e0fd8ee8fbc..6a5db1be3de 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1903,8 +1903,8 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for - // remote (SSH) worktrees. Creating symlinks on the remote host would + // Why: shared paths are intentionally not wired up for remote (SSH) + // worktrees. Creating symlinks on the remote host would // require a new relay method and authorization surface; the feature is // local-only until that protocol work is in scope. Remote repos with // `symlinkPaths` configured have them silently ignored here. @@ -2536,10 +2536,8 @@ export async function createLocalWorktree( // Why: materialize user-configured paths from the primary checkout into the // new worktree before any setup script runs, so scripts that reuse shared // state (e.g. `node_modules`, `.env`) see those paths already in place. - // Gated on the experimental flag so disabling the feature globally skips - // the work even when a repo still has paths configured. const symlinkPaths = repo.symlinkPaths ?? [] - if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) { + if (symlinkPaths.length > 0) { await timing.time('create_symlinks', async () => { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84f1954d194..2cf08c69f4d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -853,7 +853,6 @@ type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] - experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null @@ -15142,11 +15141,7 @@ export class OrcaRuntimeService { warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - if ( - settings.experimentalWorktreeSymlinks && - repo.symlinkPaths && - repo.symlinkPaths.length > 0 - ) { + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) } diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index f111d03d3b2..2aaa38d6bbb 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -43,9 +43,6 @@ export function ExperimentalPane({ const showTerminalAttention = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().terminalAttention ]) - const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry().symlinksOnWorktrees - ]) const showAgentHibernation = matchesSettingsSearch(searchQuery, [ getExperimentalSearchEntry().agentHibernation ]) @@ -322,57 +319,6 @@ export function ExperimentalPane({ ) : null} - {showWorktreeSymlinks ? ( - -
-
- -

- {translate( - 'auto.components.settings.ExperimentalPane.9762364929', - 'Uses APFS clone-copy on macOS when possible, otherwise symlinks configured folders or files into created worktrees.' - )} -

-
- -
-
- ) : null} - {hiddenExperimentalUnlocked ? : null} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 91bfa20045e..e05f13fae5d 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -81,7 +81,6 @@ export function RepositoryPane({ const runtimeSessionSummary = useAppStore( useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id)) ) - const symlinksEnabled = settings?.experimentalWorktreeSymlinks const [confirmingRemove, setConfirmingRemove] = useState(null) const [copiedTemplate, setCopiedTemplate] = useState(false) const copiedTemplateResetTimerRef = useRef(null) @@ -353,7 +352,6 @@ export function RepositoryPane({ ) : null, !isFolder && !repo.connectionId && - symlinksEnabled && (forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, symlinkEntries)) ? ( ) : null, diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index de746b3cb54..71ff93ed5b9 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -185,55 +185,6 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( ] }, getNewWorktreeCardStyleSearchEntry(), - { - title: translate( - 'auto.components.settings.experimental.search.78c2a8dc74', - 'Shared paths on worktrees' - ), - description: translate( - 'auto.components.settings.experimental.search.603d29ed74', - 'Automatically materialize configured files or folders into newly created worktrees using APFS clone-copy on macOS when possible, otherwise symlinks.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.d23ae13990', - 'worktree' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.10b52f79c1', - 'worktrees' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.c387565812', - 'symlink' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.bff1ff7768', - 'symlinks' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.3028f0bd3a', - 'link' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.f082788cfe', - 'links' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.3021571c30', - 'shared' - ), - ...translateSearchKeyword('auto.components.settings.experimental.search.4ad605f222', 'env'), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.44c7f209d5', - 'node_modules' - ) - ] - }, getEphemeralVmsSearchEntry() ] ) @@ -273,12 +224,6 @@ export function getExperimentalSearchEntry() { 'New card style' ) ), - symlinksOnWorktrees: findEntry( - translate( - 'auto.components.settings.experimental.search.78c2a8dc74', - 'Shared paths on worktrees' - ) - ), ephemeralVms: findEntry( translate('auto.components.settings.ephemeralVms.search.title', 'Per-Workspace Environments') ) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1a8a3f6ffe6..520e8cc29fc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -368,7 +368,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalNewWorktreeCardStyle: false, experimentalEphemeralVms: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, // Why: local desktop remains the default server until the user explicitly // selects a saved runtime environment. activeRuntimeEnvironmentId: null, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 1a77eb76d44..ee34b53ec3c 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -298,7 +298,6 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'experimentalWorktreeSymlinks', 'geminiCliOAuthEnabled', 'openAgentTabsInChatByDefault' ] as const satisfies readonly BooleanGlobalSettingsKey[] diff --git a/src/shared/types.ts b/src/shared/types.ts index fedd1623e46..7acf3a5e6ea 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -272,11 +272,9 @@ export type Repo = { importedExternalWorktreePaths?: string[] /** User permanently opted out of the new-external-worktree inbox for this repo. */ externalWorktreeDiscoverySuppressedAt?: number - /** Paths (relative to the primary checkout) that should be symlinked into - * newly created worktrees of this repo. Consumed only when the global - * `experimentalWorktreeSymlinks` flag is on — the per-repo list is the - * "what to link", the global flag is the "whether to link at all" switch. - * Undefined/empty means no symlinks are created for this repo. */ + /** Paths (relative to the primary checkout) that should be APFS clone-copied + * on macOS when possible, otherwise symlinked, into newly created worktrees. + * Undefined/empty means no shared paths are created for this repo. */ symlinkPaths?: string[] /** Durable sidebar-only repo organization. Execution remains repo-scoped. */ projectGroupId?: string | null @@ -2922,13 +2920,6 @@ export type GlobalSettings = { /** Legacy persisted key from the Experimental rollout. New writes use * compactWorktreeCards. */ experimentalCompactWorktreeCards?: boolean - /** Experimental: when creating a worktree, automatically symlink a - * user-configured set of files/folders from the primary checkout (e.g. - * `.env`, `node_modules`) into the new worktree. Opt-in while the - * configuration surface and edge cases (conflicts with existing paths, - * cleanup on worktree delete) are still being worked out. */ - experimentalWorktreeSymlinks: boolean - /** Active non-local runtime environment for client-routed RPC. `null` * preserves the current local desktop behavior. */ activeRuntimeEnvironmentId?: string | null From 2802f0419d402dbb04912bcfe4a2bc6fde670573 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:32:55 -0700 Subject: [PATCH 086/138] Fix browser annotation sends to active Windows agents (#8309) * fix: authorize guarded sends to active ConPTY agents * Fix stale-handle rejection using controller-less status checks and contr - Distinguish `terminal_handle_stale` (PTY rebind mid-check) from `terminal_exited` so callers get an accurate reason instead of a generic `terminal_not_writable`. - Move the PTY binding assertion to always run after the fresh foreground read, even when no controller is present, closing a gap where a rebind during a controller-less check went undetected. - Call `confirmForegroundProcess` through its owning controller instance so the method keeps its `this` receiver instead of being invoked unbound. - Update reliability gates and design doc to reflect the new test coverage and corrected error semantics. * rm design doc --- config/reliability-gates.jsonc | 165 +++++++++++++ src/main/ipc/pty.test.ts | 34 +++ src/main/ipc/pty.ts | 9 + src/main/runtime/orca-runtime.test.ts | 263 ++++++++++++++++++++- src/main/runtime/orca-runtime.ts | 88 +++++-- src/main/runtime/rpc/methods/terminal.ts | 39 ++- src/main/runtime/rpc/terminal-send.test.ts | 78 ++++++ 7 files changed, 652 insertions(+), 24 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7d8172cb5f..333eb7e8a14 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2114,6 +2114,171 @@ ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, + { + "id": "terminal-input.guarded-agent-send-authorization", + "title": "Guarded agent sends authorize only the confirmed exact PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-provider-contract", + "surfaces": [ + "terminal input", + "guarded note sends", + "agent status", + "PTY identity", + "foreground process confirmation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon" + ], + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8303" + ], + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "testFiles": [ + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/windows-conpty-process-membership.test.ts", + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/renderer/src/lib/active-agent-note-send.test.ts", + "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result", + "shell, non-agent, null, thrown, and missing confirmation remain fail-closed", + "title blockers and ordinary recognized agent evidence add no confirmation call", + "PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY", + "controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "a handle rebind during guarded status evaluation returns not writable and writes zero bytes", + "the callback's actual PTY must equal the handle binding before status can authorize a write" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "runtime confirmation reaches the provider owning the captured PTY", + "unsupported or missing providers return unavailable confirmation" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", + "unavailable process inspection is distinct from authoritative shell evidence" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "fresh confirmation is discarded when its owning local PTY exits" + ] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "exact ConPTY console membership comes from the fixed node-pty helper", + "malformed, incomplete, timed-out, and spawn-error membership reads fail closed" + ] + }, + { + "file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "assertions": [ + "daemons from before the fresh-confirmation RPC are rejected by protocol version" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "fresh confirmation bypasses cached PowerShell and waits for a post-request process scan" + ] + }, + { + "file": "src/renderer/src/lib/active-agent-note-send.test.ts", + "assertions": [ + "selected active-agent note sends retain guarded paste and submit routing" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "assertions": [ + "browser annotation send content routes through review-notes send so existing agent sessions remain selectable", + "both browser annotation send surfaces wire the shared menu content" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "result": "passed", + "durationSeconds": 9.58, + "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused runtime, RPC, PTY provider, and renderer routing units" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + }, + "promotionCriteria": [ + "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", + "Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.", + "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." + ], + "knownGaps": [ + "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", + "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", + "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", + "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." + ], + "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..94b047d8968 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3221,6 +3221,40 @@ describe('registerPtyHandlers', () => { }) }) + it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => { + const confirmForegroundProcess = vi.fn(async () => 'codex') + registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never) + setPtyOwnership('remote-pty', 'ssh-1') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') + deletePtyOwnership('remote-pty') + }) + + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { + registerSshPtyProvider('ssh-1', {} as never) + setPtyOwnership('unsupported-pty', 'ssh-1') + setPtyOwnership('missing-pty', 'missing-connection') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() + await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() + deletePtyOwnership('unsupported-pty') + deletePtyOwnership('missing-pty') + }) + it('rethrows non-not-found local provider shutdown failures', async () => { setLocalPtyProvider({ spawn: vi.fn(), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4dcc451324a..493cc494058 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3397,6 +3397,15 @@ export function registerPtyHandlers( return null } }, + confirmForegroundProcess: async (ptyId) => { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } + }, getCwd: async (ptyId) => { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..fdd1096529e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,10 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -1179,6 +1176,67 @@ const store = { getProjects: () => [] } +async function createExplicitAgentStatusHarness(options: { + getForegroundProcess: (ptyId: string) => Promise + confirmForegroundProcess?: (ptyId: string) => Promise + title?: string +}): Promise<{ + runtime: OrcaRuntimeService + handle: string + syncPty: (ptyId: string | null) => void +}> { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'working', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + confirmForegroundProcess: options.confirmForegroundProcess + }) + runtime.attachWindow(1) + const syncPty = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: options.title ?? 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId + } + ] + }) + } + syncPty('pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + return { runtime, handle: terminal.handle, syncPty } +} + function makeHeadlessTerminalLayout( ptyIdsByLeafId: Record ): TerminalLayoutSnapshot { @@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn(async () => 'claude') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(getForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + }) + + it('calls foreground confirmation with its controller receiver', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn( + async function (this: { getForegroundProcess: typeof getForegroundProcess }) { + return this.getForegroundProcess === getForegroundProcess ? 'codex' : null + } + ) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it.each([ + ['shell', async () => 'pwsh.exe'], + ['non-agent', async () => 'vim'], + ['unavailable', async () => null], + [ + 'failure', + async () => { + throw new Error('provider unavailable') + } + ] + ])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => { + const confirmForegroundProcess = vi.fn(confirm) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it('fails closed on a shell conflict when the controller cannot confirm it', async () => { + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + }) + + it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => { + const getForegroundProcess = vi.fn(async () => 'codex') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips both foreground reads when current title evidence blocks explicit hook state', async () => { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess, + title: 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips foreground reads for permission title and blocked wait evidence', async () => { + for (const blocked of ['title', 'wait'] as const) { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + runtime.onPtyData( + 'pty-1', + blocked === 'title' + ? '\x1b]0;Codex waiting for permission\x07' + : 'Hooks need review. Press enter to confirm\n', + Date.now() + 1000 + ) + getForegroundProcess.mockClear() + confirmForegroundProcess.mockClear() + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + } + }) + + it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => { + const foreground = deferred() + const getForegroundProcess = vi.fn(() => foreground.promise) + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + foreground.resolve('zsh') + + await expect(status).rejects.toThrow('terminal_handle_stale') + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('rejects a handle rebind while a controller-less status check yields', async () => { + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + runtime.setPtyController(null) + + const status = runtime.getTerminalAgentStatus(handle) + syncPty('pty-2') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the owning PTY exits', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + runtime.onPtyExit('pty-1', 0) + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_exited') + }) + it('reports permission from a title-derived action-required agent state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2cf08c69f4d..19d08a619ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1205,6 +1205,7 @@ type RuntimePtyController = { stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -10226,7 +10227,8 @@ export class OrcaRuntimeService { } async getTerminalAgentStatus(handle: string): Promise { - const terminal = this.getTerminalAgentStatusSnapshot(handle) + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = @@ -10250,7 +10252,8 @@ export class OrcaRuntimeService { // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle)) + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, @@ -10261,14 +10264,42 @@ export class OrcaRuntimeService { return { handle, isRunningAgent: true, status: terminal.titleStatus } } - return { - handle, - isRunningAgent: await this.isTerminalRunningAgent(handle), - status: null - } + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } } - private getTerminalAgentStatusSnapshot(handle: string): { + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + // Why: delayed process evidence belongs only to the PTY that started the + // read, while callers still rely on the established stale-handle contract. + throw new Error('terminal_handle_stale') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string + ): { waitText: string waitBlockedAt: number | null title: string | null @@ -10277,8 +10308,8 @@ export class OrcaRuntimeService { } { const pty = this.getLivePtyForHandle(handle) if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf @@ -10316,6 +10347,9 @@ export class OrcaRuntimeService { if (!leaf.ptyId) { throw new Error('terminal_gone') } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, @@ -10330,21 +10364,36 @@ export class OrcaRuntimeService { } } - private async terminalHasShellForegroundProcess(handle: string): Promise { + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } + let foregroundProcess: string | null try { - const pty = this.getLivePtyForHandle(handle) - const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - if (!ptyId) { - return false - } - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - return foregroundProcess !== null && isShellProcess(foregroundProcess) + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmationController = this.ptyController + if (!confirmationController?.confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( @@ -20876,7 +20925,8 @@ export class OrcaRuntimeService { // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { try { - return this.getTerminalAgentStatusSnapshot(handle).titleStatus + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9c39a65f456..7a9b9e448a6 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} + +function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} + function appendPendingMultiplexOutput( stream: TerminalMultiplexStream, data: string, @@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { throw new Error('terminal_guard_not_writable') } - const agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + let agentStatus + try { + agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + // Why: a send callback can race a pane reconnect; status evidence + // must never authorize the callback's replacement PTY. + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (!agentStatus.isRunningAgent) { throw new Error('terminal_guard_no_agent') } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 12e40c2c977..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -525,6 +525,84 @@ describe('terminal send RPC', () => { ) }) + it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => { + let boundPtyId = 'pty-1' + let statusCalls = 0 + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockImplementation(async () => { + statusCalls += 1 + if (statusCalls === 2) { + boundPtyId = 'pty-2' + } + return { + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + } + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write('pty-1', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + }) + + it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-2') + write('pty-2', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalled() + }) + it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), From 5092ba9fb873a0d3bf0cfd39b1279fc7ab4cbade Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:41:32 -0700 Subject: [PATCH 087/138] feat(resource-manager): show browser resources (#8267) --- .../ResourceUsageStatusSegment.rows.test.tsx | 27 ++++++++++ .../status-bar/ResourceUsageStatusSegment.tsx | 40 +++++++++++---- .../mergeSnapshotAndSessions.test.ts | 50 ++++++++++++++++++- .../status-bar/mergeSnapshotAndSessions.ts | 36 +++++++++++-- .../status-bar/resource-usage-merge-types.ts | 12 ++++- .../status-bar/resource-usage-open-slices.ts | 8 +++ 6 files changed, 159 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx index 57f77d1e472..b544399d9ba 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -4,6 +4,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import type { BrowserWorkspace } from '../../../../shared/types' import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' vi.mock('@/store', () => { @@ -64,6 +65,7 @@ function makeWorktree(overrides: Partial): UnifiedWorktreeRo hasLocalSamples: true, isRemote: false, sessions: [], + browsers: [], ...overrides } } @@ -131,4 +133,29 @@ describe('resource manager row presentation', () => { expect(container.querySelector('button[aria-label="Kill session orphan-a"]')).not.toBeNull() }) + + it('shows browsers as read-only workspace resources', () => { + renderWorktreeRow( + makeWorktree({ + browsers: [ + { + id: 'browser-1', + worktreeId: 'wt-1', + title: 'Orca docs', + url: 'https://docs.orca.dev', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } as BrowserWorkspace + ] + }) + ) + + expect(container.textContent).toContain('Orca docs') + expect(container.querySelector('.lucide-globe')).not.toBeNull() + expect(container.querySelector('button[aria-label^="Open browser"]')).toBeNull() + }) }) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 8d3ed2982e9..5c9e580b5fa 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -9,6 +9,7 @@ import { AlertTriangle, ChevronDown, ChevronRight, + Globe, LoaderCircle, MemoryStick, RotateCw, @@ -35,7 +36,7 @@ import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' -import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' +import type { AppMemory, BrowserWorkspace, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { isFolderRepo } from '../../../../shared/repo-kind' @@ -56,6 +57,7 @@ import { } from './resource-session-navigation' import { getResourceUsageAllWorktrees, + getResourceUsageBrowserTabsByWorktree, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -438,6 +440,18 @@ export function SessionRow({ ) } +function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { + const label = browser.title?.trim() || browser.label?.trim() || browser.url + return ( +
+ + {label} + + +
+ ) +} + // ─── Worktree row ─────────────────────────────────────────────────── export function WorktreeRow({ @@ -461,7 +475,7 @@ export function WorktreeRow({ onKillSession: (session: UnifiedSessionRow) => void navigateToTab: (tabId: string, paneKey: string | null) => void }): React.JSX.Element { - const hasSessions = worktree.sessions.length > 0 + const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 // Why: synthetic buckets (orphan/unattributed) have no sidebar target to // reveal. Real and SSH-resolved worktrees both qualify for navigation — // navigateToWorktree handles the no-store-record case internally by @@ -481,7 +495,7 @@ export function WorktreeRow({ return (
- {hasSessions ? ( + {hasResources ? (
) } @@ -776,6 +792,7 @@ export function ResourceUsageStatusSegment({ const repos = useAppStore((s) => getResourceUsageRepos(s, open)) const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open)) // Why: the closed trigger owns a scalar selector. Full binding maps stay // behind open sentinels so unchanged counts do not rerender the segment. const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open)) @@ -922,6 +939,10 @@ export function ResourceUsageStatusSegment({ }, [repos]) const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + const worktreeById = useMemo( + () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), + [allWorktrees] + ) const oldWorkspaceCount = useMemo(() => { const now = Date.now() @@ -954,7 +975,9 @@ export function ResourceUsageStatusSegment({ workspaceSessionReady, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById + repoRuntimeScopedById, + browserTabsByWorktree, + worktreeById }) : [], [ @@ -968,7 +991,9 @@ export function ResourceUsageStatusSegment({ workspaceSessionReady, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById + repoRuntimeScopedById, + browserTabsByWorktree, + worktreeById ] ) @@ -1252,10 +1277,7 @@ export function ResourceUsageStatusSegment({
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc', - 'Resource Manager - Terminals' - )} + {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index e542f90a818..831d4ad90e3 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { MemorySnapshot, TerminalTab, WorktreeMemory } from '../../../../shared/types' +import type { + BrowserWorkspace, + MemorySnapshot, + TerminalTab, + Worktree, + WorktreeMemory +} from '../../../../shared/types' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { DaemonSession, MergeContext } from './resource-usage-merge-types' @@ -59,6 +65,48 @@ describe('mergeSnapshotAndSessions', () => { expect(mergeSnapshotAndSessions(null, [], baseCtx())).toEqual([]) }) + it('includes browser-only workspaces in their repo', () => { + const worktree = { + id: 'orca::/Users/me/browser-only', + repoId: 'orca', + displayName: 'browser-only' + } as Worktree + const browser = { + id: 'browser-1', + worktreeId: worktree.id, + title: 'Orca docs', + url: 'https://docs.orca.dev', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } as BrowserWorkspace + const out = mergeSnapshotAndSessions( + null, + [], + baseCtx({ + repoDisplayNameById: new Map([['orca', 'ORCA']]), + worktreeById: new Map([[worktree.id, worktree]]), + browserTabsByWorktree: { [worktree.id]: [browser] } + }) + ) + + expect(out[0]).toMatchObject({ + repoId: 'orca', + repoName: 'ORCA', + worktrees: [ + { + worktreeId: worktree.id, + worktreeName: 'browser-only', + sessions: [], + browsers: [browser] + } + ] + }) + }) + it('passes through snapshot worktrees with numeric metrics and hasLocalSamples', () => { const wt: WorktreeMemory = { worktreeId: 'orca::/Users/me/Triton', diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index e4d12744f90..7c7f5499f71 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -218,7 +218,8 @@ export function mergeSnapshotAndSessions( history: wt.history, hasLocalSamples: true, isRemote: isRepoRemote(wt.repoId), - sessions + sessions, + browsers: [] }) } } @@ -276,7 +277,8 @@ export function mergeSnapshotAndSessions( history: [], hasLocalSamples: false, isRemote: repoIsRemote, - sessions: [] + sessions: [], + browsers: [] } repo.worktrees.push(row) } @@ -294,7 +296,35 @@ export function mergeSnapshotAndSessions( }) } - // ── Step 3: per-repo aggregates. Remote children are identified by the + // ── Step 3: add browser resources, including browser-only workspaces. + for (const [worktreeId, browsers] of Object.entries(ctx.browserTabsByWorktree ?? {})) { + const worktree = ctx.worktreeById?.get(worktreeId) + if (!worktree || browsers.length === 0) { + continue + } + const repoName = ctx.repoDisplayNameById.get(worktree.repoId) || worktree.repoId + const repo = ensureRepo(worktree.repoId, repoName) + let row = findWorktreeRow(repo, worktreeId) + if (!row) { + row = { + worktreeId, + worktreeName: worktree.displayName, + repoId: worktree.repoId, + repoName, + cpu: null, + memory: null, + history: [], + hasLocalSamples: false, + isRemote: isRepoRemote(worktree.repoId), + sessions: [], + browsers: [] + } + repo.worktrees.push(row) + } + row.browsers = browsers + } + + // ── Step 4: per-repo aggregates. Remote children are identified by the // repo's connectionId, not by missing data — `!hasLocalSamples` would // mislabel warm-reattached local PTYs. The aggregate still skips rows // we can't sample (worktree.cpu === null) so the numbers stay honest. diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 7e208d51817..a3adf534443 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,9 @@ -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { + BrowserWorkspace, + TerminalLayoutSnapshot, + TerminalTab, + Worktree +} from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -33,6 +38,7 @@ export type UnifiedWorktreeRow = { /** Why: repo connectionId, not sample presence, drives the remote chip. */ isRemote: boolean sessions: UnifiedSessionRow[] + browsers: BrowserWorkspace[] } export type UnifiedProjectGroup = { @@ -62,4 +68,8 @@ export type MergeContext = { repoConnectionIdById: Map /** Repo runtime-host scope by repo id (missing == keep row). */ repoRuntimeScopedById: Map + /** Browser inventory is open-only; the Resource Manager never scans it in the background. */ + browserTabsByWorktree?: Record + /** Canonical worktrees keep browser-only workspace rows out of synthetic buckets. */ + worktreeById?: ReadonlyMap } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index 7b5e7ba6d5b..c4bed3ecd15 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -5,6 +5,7 @@ const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} +const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] @@ -36,6 +37,13 @@ export function getResourceUsageRuntimePaneTitlesByTabId( return open ? state.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID } +export function getResourceUsageBrowserTabsByWorktree( + state: Pick, + open: boolean +): AppState['browserTabsByWorktree'] { + return open ? state.browserTabsByWorktree : EMPTY_BROWSER_TABS_BY_WORKTREE +} + export function getResourceUsageRepos( state: Pick, open: boolean From 6be8687c343123d6d59fc6a89c289f84362eff06 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:49:49 -0700 Subject: [PATCH 088/138] feat(status-bar): notify upgraded users usage meters show % used (#8319) * feat(status-bar): notify upgraded users usage meters show % used Show a one-time status-bar callout when upgraded profiles still use the new percent-used default. Brand-new profiles and users who already chose remaining stay quiet; dismissing or changing the setting is permanent. * Add settings deep-link to expand Appearance's Window accordion for one-s - Replaces the searchQuery-based redirect (fragile, flashed filter UI) with a dedicated appearanceAccordionDeepLink store field that force-opens the correct accordion and scrolls to the target row - Rebuilds the status-bar change notice as a plain elevated card instead of a Popover, since PopoverContent's glass/backdrop-filter defaults fought the opaque callout styling and needed heavy overrides - Simplifies the light/dark card CSS tokens accordingly * Reposition status-bar usage-change notice via fixed-position portal Portal the one-shot callout to document.body with fixed positioning anchored via getBoundingClientRect, since the status-bar's overflow-hidden flex ancestors clipped or mispositioned the previous in-tree absolute layout. * Refine status-bar usage notice styling and test coverage - Replace hand-tuned light/dark card colors with existing design tokens (--popover, --border) and the documented floating elevation, so the callout stays in sync with the design system instead of duplicating its own palette - Add tests covering dismiss via X button, "Got it", and Escape - Scope the foreground-process confirm assertion to the pane's ptyId so an unrelated pane's delayed confirm can't cause a false failure --- src/main/persistence.test.ts | 44 ++++ src/main/persistence.ts | 22 ++ src/main/runtime/rpc/methods/client-ui.ts | 1 + src/renderer/src/assets/main.css | 25 ++ .../settings/AppearancePane.test.tsx | 4 +- .../components/settings/AppearancePane.tsx | 27 ++- .../AppearanceWindowSidebarSection.tsx | 2 + .../src/components/settings/Settings.tsx | 20 +- ...appearance-usage-percentage-search.test.ts | 16 ++ .../appearance-usage-percentage-search.ts | 19 ++ .../src/components/status-bar/StatusBar.tsx | 16 +- ...sagePercentageDisplayChangeNotice.test.tsx | 225 ++++++++++++++++++ .../UsagePercentageDisplayChangeNotice.tsx | 215 +++++++++++++++++ .../terminal-pane/pty-connection.test.ts | 4 +- src/renderer/src/store/slices/ui.test.ts | 26 +- src/renderer/src/store/slices/ui.ts | 44 +++- src/shared/constants.ts | 3 + src/shared/types.ts | 4 + ...e-percentage-display-change-notice.test.ts | 118 +++++++++ .../usage-percentage-display-change-notice.ts | 45 ++++ .../e2e-completed-onboarding-profile.ts | 5 +- 21 files changed, 872 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts create mode 100644 src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx create mode 100644 src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx create mode 100644 src/shared/usage-percentage-display-change-notice.test.ts create mode 100644 src/shared/usage-percentage-display-change-notice.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4e921151e4f..6fefe99834c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -562,6 +562,50 @@ describe('Store', () => { expect(ui.setupGuideSidebarDismissed).toBe(false) expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true) expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) + // Why: brand-new profiles never saw remaining-as-default. + expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true) + }) + + it('surfaces the usage percentage display change notice for upgraded profiles', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + // Why: omit the notice key so load resolves eligibility for existing profiles. + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + }) + + it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + usagePercentageDisplay: 'remaining', + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('defaults minimizeToTrayOnClose to false when unset', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 19cce1bbacb..2fc2704f488 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -105,6 +105,8 @@ import { } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' +import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -3306,6 +3308,25 @@ export class Store { ) { this.loadNeedsSave = true } + // Why: only upgraded profiles that still use the new default get + // the one-time usage-display change notice; brand-new profiles and + // users who already chose remaining stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + this.loadNeedsSave = true + } return { ...defaults.ui, // Why: missing card properties should follow the persisted card @@ -3319,6 +3340,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 271fcefad75..d262890d8d7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -239,6 +239,7 @@ const UiUpdate = z trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 56841b77245..25a5a4e751b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1209,6 +1209,31 @@ background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar)); } +/* Why: one-shot status-bar callout is portaled fixed above the meters. + Opaque popover surface (not glass) so titlebar/terminal chrome cannot + bleed through; use design tokens + the documented floating elevation. */ +.status-bar-change-notice-card { + border: 1px solid var(--border); + background: var(--popover); + color: var(--popover-foreground); + /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */ + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); +} + +/* Why: caret points down at the usage meters this notice explains. */ +.status-bar-change-notice-card::after { + content: ''; + position: absolute; + left: 1.25rem; + bottom: -6px; + width: 12px; + height: 12px; + transform: rotate(45deg); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--popover); +} + /* Why: the first-run Mobile Emulator intro sits directly under that menu row; an upward caret aligned to the phone icon reads as a callout, not a footer. */ .mobile-emulator-tab-intro-callout--menu { diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index dc5a4abd1aa..ca68ff7e47c 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -23,7 +23,9 @@ const mocks = vi.hoisted(() => ({ usagePercentageDisplay: 'used' as 'used' | 'remaining', setUsagePercentageDisplay: vi.fn(), recordFeatureInteraction: vi.fn(), - setWorktreeCardMode: vi.fn() + setWorktreeCardMode: vi.fn(), + appearanceAccordionDeepLink: null as 'interface' | 'terminal' | 'window' | null, + clearAppearanceAccordionDeepLink: vi.fn() } })) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 92792621533..631059d6d3a 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useState } from 'react' +import { useLayoutEffect, useState } from 'react' import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' @@ -10,6 +10,7 @@ import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' import { useAppStore } from '../../store' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percentage-search' import { getAppIconEntries, getAppearancePaneSearchEntries, @@ -75,6 +76,10 @@ export function AppearancePane({ warpThemes }: AppearancePaneProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) + const appearanceAccordionDeepLink = useAppStore((state) => state.appearanceAccordionDeepLink) + const clearAppearanceAccordionDeepLink = useAppStore( + (state) => state.clearAppearanceAccordionDeepLink + ) const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const isWebClient = isWebClientLocation() // Why: the system tray behavior is desktop-Electron Windows-only; a Windows @@ -84,6 +89,26 @@ export function AppearancePane({ const [manuallyOpenSection, setManuallyOpenSection] = useState( 'interface' ) + + // Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar; + // expand that accordion before Settings scrolls so the row is actually visible. + useLayoutEffect(() => { + if (!appearanceAccordionDeepLink) { + return + } + setManuallyOpenSection(appearanceAccordionDeepLink) + clearAppearanceAccordionDeepLink() + // Why: accordion expand is layout-synchronous; scroll on the next frame so + // the target has non-zero height when Settings (or this fallback) scrolls. + const frameId = requestAnimationFrame(() => { + document + .getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID) + ?.scrollIntoView({ block: 'nearest' }) + }) + return () => { + cancelAnimationFrame(frameId) + } + }, [appearanceAccordionDeepLink, clearAppearanceAccordionDeepLink]) const interfaceTitle = translate( 'auto.components.settings.AppearancePane.interfaceTitle', 'Interface' diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 93b9f94535e..3228c54669c 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -18,6 +18,7 @@ import { getStatusBarToggles, getUsagePercentageDisplayEntry } from './appearance-search' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from './appearance-usage-percentage-search' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' import { getLeftSidebarAppearanceEntry, @@ -130,6 +131,7 @@ export function AppearanceWindowSidebarSection({ {showStatusBarControls ? (
signal + 1) } @@ -892,7 +901,16 @@ function Settings(): React.JSX.Element { const scrollTargetId = pendingScrollTargetRef.current const pendingNavSectionId = pendingNavSectionRef.current - if (scrollTargetId && pendingNavSectionId && settingsSearchQuery.trim() !== '') { + // Why: subsection deep links (scrollTarget ≠ pane id) must not keep a + // leftover search filter that can hide the target row. Pane-level deep + // links may intentionally pair with a filter (e.g. Appearance + "Usage + // percentages") so accordion sections force-open to the matching control. + if ( + scrollTargetId && + pendingNavSectionId && + scrollTargetId !== pendingNavSectionId && + settingsSearchQuery.trim() !== '' + ) { setSettingsSearchQuery('') return } diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts new file mode 100644 index 00000000000..fdb30dd8d80 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { + resolveAppearanceAccordionDeepLink, + USAGE_PERCENTAGE_DISPLAY_SETTING_ID +} from './appearance-usage-percentage-search' + +describe('resolveAppearanceAccordionDeepLink', () => { + it('maps the usage percentage row to the Window accordion', () => { + expect(resolveAppearanceAccordionDeepLink(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)).toBe('window') + }) + + it('returns null for unknown or missing section ids', () => { + expect(resolveAppearanceAccordionDeepLink(undefined)).toBeNull() + expect(resolveAppearanceAccordionDeepLink('something-else')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts index 9375c7c9fd1..b7bbc49218b 100644 --- a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -2,6 +2,25 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +/** Stable Settings deep-link / scroll target for the Used/Remaining control. */ +export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display' + +/** Appearance accordion keys that can be force-opened for nested deep links. */ +export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window' + +/** + * Map a Settings subsection id to the Appearance accordion that must be open + * before the row is visible (collapsed accordion content is not user-visible). + */ +export function resolveAppearanceAccordionDeepLink( + sectionId: string | undefined +): AppearanceAccordionSection | null { + if (sectionId === USAGE_PERCENTAGE_DISPLAY_SETTING_ID) { + return 'window' + } + return null +} + export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({ title: translate( 'auto.components.settings.appearance.search.usagePercentageDisplayTitle', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 281c968fa2f..b3cc8a922c1 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -63,6 +63,7 @@ import { UpdateStatusSegment } from './UpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta' +import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { useShortcutLabel } from '@/hooks/useShortcutLabel' @@ -2015,7 +2016,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const showPorts = statusBarItems.includes('ports') const showFloatingTerminalToggle = floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' - const anyVisible = + // Why: which usage-meter children actually render — excludes resource-usage + // and other non-meter status items so the % display change callout only + // opens when there is a real meter cluster to anchor to. + const hasVisibleUsageMeters = showClaude || showCodex || showGemini || @@ -2023,8 +2027,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showKimi || showAntigravity || showMiniMax || - showGrok || - showResourceUsage + showGrok + const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage @@ -2082,7 +2086,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele ) : null ) : ( - <> + // Why: one-time usage-display change callout anchors to this cluster so + // it sits next to the meters the user is confused by, not a global toast. + {showClaude && ( )} @@ -2155,7 +2161,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} - + )} {anyVisible && !isEmptyUsageState && ( diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx new file mode 100644 index 00000000000..cb8fea8a750 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search' + +const storeState = { + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + dismissUsagePercentageDisplayChangeNotice: vi.fn(), + statusBarVisible: true, + activeModal: 'none' as string, + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { + getState: () => storeState + } + ) +})) + +describe('UsagePercentageDisplayChangeNotice', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.useFakeTimers() + storeState.persistedUIReady = true + storeState.usagePercentageDisplayChangeNoticeDismissed = false + storeState.statusBarVisible = true + storeState.activeModal = 'none' + storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn() + storeState.openSettingsPage = vi.fn() + storeState.openSettingsTarget = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + // Why: fixed positioning reads getBoundingClientRect; happy-dom needs a + // non-zero layout box so the portal card is measured and mounted. + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 24, + y: 700, + top: 700, + left: 24, + bottom: 724, + right: 200, + width: 176, + height: 24, + toJSON: () => ({}) + }) satisfies DOMRect + }) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + document.querySelectorAll('.status-bar-change-notice-card').forEach((node) => node.remove()) + vi.useRealTimers() + }) + + it('portals the callout above the usage-meter anchor after a short delay', () => { + act(() => { + root.render( + + usage-meters + + ) + }) + + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + act(() => { + vi.advanceTimersByTime(1_800) + }) + const card = document.querySelector('.status-bar-change-notice-card') as HTMLElement | null + expect(card).not.toBeNull() + expect(card?.parentElement).toBe(document.body) + expect(card?.style.left).toBe('24px') + // viewport height default in happy-dom is often 768; bottom = 768 - 700 + 10 + expect(card?.style.bottom).toBeTruthy() + expect(document.body.textContent).toContain('Usage now shows % used') + expect(container.textContent).toContain('usage-meters') + }) + + it('does not open when no usage meters are visible', () => { + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('does not open when the notice was already dismissed', () => { + storeState.usagePercentageDisplayChangeNoticeDismissed = true + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('does not open while another modal is open', () => { + storeState.activeModal = 'feature-tips' + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(2_000) + }) + expect(document.querySelector('.status-bar-change-notice-card')).toBeNull() + }) + + it('deep-links to the Usage percentages setting without a search filter', () => { + const callOrder: string[] = [] + storeState.openSettingsPage = vi.fn(() => { + callOrder.push('openSettingsPage') + }) + storeState.openSettingsTarget = vi.fn(() => { + callOrder.push('openSettingsTarget') + }) + storeState.dismissUsagePercentageDisplayChangeNotice = vi.fn() + + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(1_800) + }) + + const openSettingsButton = Array.from( + document.querySelectorAll('.status-bar-change-notice-card button') + ).find((button) => button.textContent === 'Open Settings') + expect(openSettingsButton).toBeTruthy() + act(() => { + openSettingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(callOrder).toEqual(['openSettingsPage', 'openSettingsTarget']) + expect(storeState.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'appearance', + repoId: null, + sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalled() + }) + + it('dismisses from the X button', () => { + openNotice() + + const dismissButton = document.querySelector( + '.status-bar-change-notice-card button[aria-label="Dismiss"]' + ) + expect(dismissButton).toBeTruthy() + act(() => { + dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + it('dismisses from Got it', () => { + openNotice() + + const gotItButton = Array.from( + document.querySelectorAll('.status-bar-change-notice-card button') + ).find((button) => button.textContent === 'Got it') + expect(gotItButton).toBeTruthy() + act(() => { + gotItButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + it('dismisses on Escape', () => { + openNotice() + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(storeState.dismissUsagePercentageDisplayChangeNotice).toHaveBeenCalledTimes(1) + }) + + function openNotice(): void { + act(() => { + root.render( + + usage-meters + + ) + }) + act(() => { + vi.advanceTimersByTime(1_800) + }) + expect(document.querySelector('.status-bar-change-notice-card')).not.toBeNull() + } +}) diff --git a/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx new file mode 100644 index 00000000000..5c7263130cc --- /dev/null +++ b/src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.tsx @@ -0,0 +1,215 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { BarChart3, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { shouldShowUsagePercentageDisplayChangeNotice } from '../../../../shared/usage-percentage-display-change-notice' +import { USAGE_PERCENTAGE_DISPLAY_SETTING_ID } from '../settings/appearance-usage-percentage-search' + +// Why: let startup modals settle before the status-bar callout competes for focus. +const SHOW_DELAY_MS = 1_800 +// Why: gap between the top of the status-bar meters and the bottom of the card. +const ANCHOR_GAP_PX = 10 +const CARD_WIDTH_PX = 320 + +type AnchorPosition = { + bottom: number + left: number +} + +function openUsagePercentageSettings(): void { + const store = useAppStore.getState() + // Why: openSettingsPage wipes any leftover search; do not re-apply a search + // filter — deep-link to the stable row id and let Appearance expand Window. + store.openSettingsPage() + store.openSettingsTarget({ + pane: 'appearance', + repoId: null, + sectionId: USAGE_PERCENTAGE_DISPLAY_SETTING_ID + }) +} + +function measureAnchorPosition(anchor: HTMLElement): AnchorPosition { + const rect = anchor.getBoundingClientRect() + const maxLeft = Math.max(8, window.innerWidth - CARD_WIDTH_PX - 8) + return { + // Why: fixed + bottom keeps the card glued above the meters as the window + // resizes; CSS bottom is distance from the viewport bottom edge. + bottom: Math.max(8, window.innerHeight - rect.top + ANCHOR_GAP_PX), + left: Math.min(Math.max(8, rect.left), maxLeft) + } +} + +/** + * One-time elevated callout anchored above the status-bar usage meters after + * the default flipped from remaining → used. Permanent dismiss only. + * + * Why fixed + portal: status-bar ancestors use overflow-hidden flex shells, so + * in-tree absolute positioning clips or attaches to the wrong containing block. + */ +export function UsagePercentageDisplayChangeNotice({ + children, + hasVisibleUsageMeters +}: { + children: ReactNode + // Why: StatusBar owns which meter children actually render (status-bar items, + // CLI detection, MiniMax/Grok durability). Don't re-derive empty-state here. + hasVisibleUsageMeters: boolean +}): React.JSX.Element { + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const dismissed = useAppStore((s) => s.usagePercentageDisplayChangeNoticeDismissed) + const dismiss = useAppStore((s) => s.dismissUsagePercentageDisplayChangeNotice) + const statusBarVisible = useAppStore((s) => s.statusBarVisible) + const activeModal = useAppStore((s) => s.activeModal) + const [delayElapsed, setDelayElapsed] = useState(false) + const anchorRef = useRef(null) + const [anchorPosition, setAnchorPosition] = useState(null) + + const eligible = shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady, + usagePercentageDisplayChangeNoticeDismissed: dismissed, + statusBarVisible, + hasVisibleUsageMeters, + activeModal + }) + + useEffect(() => { + if (!eligible) { + setDelayElapsed(false) + return + } + const timer = window.setTimeout(() => { + setDelayElapsed(true) + }, SHOW_DELAY_MS) + return () => { + window.clearTimeout(timer) + } + }, [eligible]) + + const open = eligible && delayElapsed + + useLayoutEffect(() => { + if (!open) { + setAnchorPosition(null) + return + } + const anchor = anchorRef.current + if (!anchor) { + return + } + + const update = (): void => { + setAnchorPosition(measureAnchorPosition(anchor)) + } + update() + + // Why: meters reflow when the status bar goes compact/icon-only or the + // window resizes; keep the fixed card locked to the live anchor box. + const observer = new ResizeObserver(update) + observer.observe(anchor) + window.addEventListener('resize', update) + return () => { + observer.disconnect() + window.removeEventListener('resize', update) + } + }, [open]) + + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault() + dismiss() + } + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [dismiss, open]) + + const openSettings = (): void => { + dismiss() + openUsagePercentageSettings() + } + + const card = + open && anchorPosition + ? createPortal( +
+
+
+
+ +
+ {translate( + 'auto.components.status.bar.UsagePercentageDisplayChangeNotice.title', + 'Usage now shows % used' + )} +
+
+

+ {translate( + 'auto.components.status.bar.UsagePercentageDisplayChangeNotice.body', + 'Prefer remaining? Change it in Settings.' + )} +

+
+ +
+
+ + +
+
, + document.body + ) + : null + + return ( + <> +
+ {children} +
+ {card} + + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index c2815c88496..ae29c1e3589 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17599,7 +17599,9 @@ describe('connectPanePty', () => { binding.sampleForegroundAgentOnFocus() await vi.advanceTimersByTimeAsync(10_000) - expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + // Scope to this pane's pty id: a delayed confirm for another test's + // default `tab-pty` pane can fire during this advance and is not our subject. + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalledWith(ptyId) } finally { restoreUserAgent() } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 9e2e82e0cb4..3e5b8d928e5 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1268,12 +1268,36 @@ describe('createUISlice hydratePersistedUI', () => { store.getState().setUsagePercentageDisplay('used') expect(store.getState().usagePercentageDisplay).toBe('used') - expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' }) + // Why: adapting the control also permanently dismisses the one-time change notice. + expect(setUI).toHaveBeenCalledWith({ + usagePercentageDisplay: 'used', + usagePercentageDisplayChangeNoticeDismissed: true + }) + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(true) store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' })) expect(store.getState().usagePercentageDisplay).toBe('remaining') }) + it('hydrates and dismisses the usage percentage display change notice', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ usagePercentageDisplayChangeNoticeDismissed: false })) + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + + store.getState().dismissUsagePercentageDisplayChangeNotice() + expect(store.getState().usagePercentageDisplayChangeNoticeDismissed).toBe(true) + expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplayChangeNoticeDismissed: true }) + + setUI.mockClear() + store.getState().dismissUsagePercentageDisplayChangeNotice() + expect(setUI).not.toHaveBeenCalled() + }) + it('defaults invalid usage percentage display values to used', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a0f6aa0db73..bcc32db82f6 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -747,6 +747,16 @@ export type UISlice = { } | null openSettingsTarget: (target: NonNullable) => void clearSettingsTarget: () => void + /** + * One-shot Appearance accordion to expand for nested Settings deep links + * (e.g. Usage percentages lives under Window & Sidebar). Cleared when + * Appearance consumes it. + */ + appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null + setAppearanceAccordionDeepLink: ( + section: NonNullable + ) => void + clearAppearanceAccordionDeepLink: () => void activeModal: | 'none' | 'create-worktree' @@ -827,6 +837,8 @@ export type UISlice = { dismissMobileEmulatorAgentSetup: () => void projectOrderManualDefaultNoticeDismissed: boolean dismissProjectOrderManualDefaultNotice: () => void + usagePercentageDisplayChangeNoticeDismissed: boolean + dismissUsagePercentageDisplayChangeNotice: () => void usageEmptyStateDismissed: boolean dismissUsageEmptyState: () => void groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' @@ -1497,6 +1509,9 @@ export const createUISlice: StateCreator = (set, get) settingsNavigationTarget: null, openSettingsTarget: (target) => set({ settingsNavigationTarget: target }), clearSettingsTarget: () => set({ settingsNavigationTarget: null }), + appearanceAccordionDeepLink: null, + setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), + clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }), activeModal: 'none', modalData: {}, @@ -1922,6 +1937,17 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) return { projectOrderManualDefaultNoticeDismissed: true } }), + // Why: defaults true so pre-hydration / brand-new sessions never flash the + // change notice before persistence resolves eligibility. + usagePercentageDisplayChangeNoticeDismissed: true, + dismissUsagePercentageDisplayChangeNotice: () => + set((s) => { + if (s.usagePercentageDisplayChangeNoticeDismissed) { + return s + } + window.api.ui.set({ usagePercentageDisplayChangeNoticeDismissed: true }).catch(console.error) + return { usagePercentageDisplayChangeNoticeDismissed: true } + }), usageEmptyStateDismissed: false, dismissUsageEmptyState: () => set((s) => { @@ -2122,8 +2148,18 @@ export const createUISlice: StateCreator = (set, get) usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, setUsagePercentageDisplay: (display) => { const normalized = normalizeUsagePercentageDisplay(display) - window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error) - set({ usagePercentageDisplay: normalized }) + // Why: changing the control is the discovery path; permanently dismiss the + // one-time change notice so it does not reappear after the user adapted. + window.api.ui + .set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + .catch(console.error) + set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) }, workspacePortScan: null, workspacePortScansByKey: {}, @@ -2467,6 +2503,10 @@ export const createUISlice: StateCreator = (set, get) mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, projectOrderManualDefaultNoticeDismissed: ui.projectOrderManualDefaultNoticeDismissed === true, + // Why: load() resolves this for existing vs new profiles; treat only + // explicit true as dismissed so a false from migration still surfaces. + usagePercentageDisplayChangeNoticeDismissed: + ui.usagePercentageDisplayChangeNoticeDismissed === true, // Why: default false when undefined so existing users still see the CTA; // only an explicit dismissal persists true. usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 520e8cc29fc..a14ec445fa4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -511,6 +511,9 @@ export function getDefaultUIState(): PersistedUIState { // Why: brand-new profiles never saw recent project ordering; only upgraded // profiles get the one-time sidebar notice on first launch. projectOrderManualDefaultNoticeDismissed: true, + // Why: brand-new profiles never saw percent-remaining as the default; only + // upgraded profiles get the one-time usage-display change notice. + usagePercentageDisplayChangeNoticeDismissed: true, workspaceCleanup: { dismissals: {} }, featureTipsSeenIds: [], featureInteractions: {}, diff --git a/src/shared/types.ts b/src/shared/types.ts index 7acf3a5e6ea..232811d5981 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3348,6 +3348,10 @@ export type PersistedUIState = { /** One-shot rollout notice for manual project ordering becoming the default. * Absent or true means the sidebar callout stays hidden. */ projectOrderManualDefaultNoticeDismissed?: boolean + /** One-shot notice that status-bar usage meters now show percent used (not + * remaining). Absent is resolved on load: brand-new profiles default to + * dismissed; upgraded profiles see the notice once. */ + usagePercentageDisplayChangeNoticeDismissed?: boolean /** User-hidden empty-state usage CTA in the status bar. Permanently hides the * "Connect AI accounts to see usage" prompt even if all providers are later * disconnected — a dismissed teaching nudge stays dismissed. */ diff --git a/src/shared/usage-percentage-display-change-notice.test.ts b/src/shared/usage-percentage-display-change-notice.test.ts new file mode 100644 index 00000000000..4942aefe900 --- /dev/null +++ b/src/shared/usage-percentage-display-change-notice.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + resolveUsagePercentageDisplayChangeNoticeDismissed, + shouldShowUsagePercentageDisplayChangeNotice +} from './usage-percentage-display-change-notice' + +describe('resolveUsagePercentageDisplayChangeNoticeDismissed', () => { + it('keeps an explicit dismissal', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: true, + rawUsagePercentageDisplay: undefined, + isExistingProfile: true + }) + ).toBe(true) + }) + + it('hides the notice for brand-new profiles', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: undefined, + isExistingProfile: false + }) + ).toBe(true) + }) + + it('hides the notice when the user already chose remaining', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: 'remaining', + isExistingProfile: true + }) + ).toBe(true) + }) + + it('shows the notice for upgraded profiles still on used/missing default', () => { + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: undefined, + isExistingProfile: true + }) + ).toBe(false) + expect( + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: undefined, + rawUsagePercentageDisplay: 'used', + isExistingProfile: true + }) + ).toBe(false) + }) +}) + +describe('shouldShowUsagePercentageDisplayChangeNotice', () => { + it('requires ready UI, undismissed state, visible usage meters, and no modal', () => { + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(true) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: false, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: true, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: false, + hasVisibleUsageMeters: true, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: false, + activeModal: 'none' + }) + ).toBe(false) + + expect( + shouldShowUsagePercentageDisplayChangeNotice({ + persistedUIReady: true, + usagePercentageDisplayChangeNoticeDismissed: false, + statusBarVisible: true, + hasVisibleUsageMeters: true, + activeModal: 'feature-tips' + }) + ).toBe(false) + }) +}) diff --git a/src/shared/usage-percentage-display-change-notice.ts b/src/shared/usage-percentage-display-change-notice.ts new file mode 100644 index 00000000000..caeaa6ccfb6 --- /dev/null +++ b/src/shared/usage-percentage-display-change-notice.ts @@ -0,0 +1,45 @@ +/** + * Resolve whether the one-time "usage now shows percent used" notice is + * already dismissed for this profile. + * + * - Explicit dismissal stays dismissed. + * - Brand-new profiles never saw percent-remaining as the default, so they + * should not get a change notice. + * - Users who already chose "remaining" have found the setting; no need to + * teach the flip. + * - Upgraded profiles still on the new default ("used"/missing) get the notice. + */ +export function resolveUsagePercentageDisplayChangeNoticeDismissed(args: { + rawDismissed: unknown + rawUsagePercentageDisplay: unknown + isExistingProfile: boolean +}): boolean { + if (args.rawDismissed === true) { + return true + } + if (!args.isExistingProfile) { + return true + } + // Why: choosing remaining is the discovery path; re-teaching the default flip + // would only interrupt someone who already adapted. + if (args.rawUsagePercentageDisplay === 'remaining') { + return true + } + return false +} + +export function shouldShowUsagePercentageDisplayChangeNotice(args: { + persistedUIReady: boolean + usagePercentageDisplayChangeNoticeDismissed: boolean + statusBarVisible: boolean + hasVisibleUsageMeters: boolean + activeModal: string +}): boolean { + return ( + args.persistedUIReady && + !args.usagePercentageDisplayChangeNoticeDismissed && + args.statusBarVisible && + args.hasVisibleUsageMeters && + args.activeModal === 'none' + ) +} diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 6161f1579a3..3ffefb1e770 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -38,7 +38,10 @@ export function getE2ECompletedOnboardingProfile() { ), contextualToursSeenIds: [...SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS], contextualToursAutoEligible: false, - projectOrderManualDefaultNoticeDismissed: true + projectOrderManualDefaultNoticeDismissed: true, + // Why: E2E profiles model completed existing users and should not be + // interrupted by the usage-display change toast covering the UI under test. + usagePercentageDisplayChangeNoticeDismissed: true } } } From ac8879c789df450eafb9883a13b015e5a57504e7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:51:52 -0700 Subject: [PATCH 089/138] feat(markdown): support HTML superscript links (#8307) * feat(markdown): support HTML superscript links * Add HTML superscript citation links to the rich markdown editor - Introduce marked-based tokenizer support for `` citation links, with a shared editor context so key/link handlers can resolve selection status and open an action bubble for citation atoms (which markdown setLink/unsetLink can't edit). - Extract clipboard-write logic shared by cut and cut-visual-line into rich-markdown-clipboard-write.ts, and surface a cut-limit error when clipboard readback fails. - Fix MarkdownPreview same-file anchor scrolling to run before the unknown-ownership guard so ambiguous folder-workspace ownership still scrolls within the open document. - Keep search's replace-disabled state derived from live matches instead of a stale snapshot. * Update lockfile to dedupe stale package versions after dependency resolu - Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install - Aligns transitive deps to the single resolved versions already in use elsewhere in the tree --- package.json | 1 + pnpm-lock.yaml | 195 +----- .../src/assets/rich-markdown-editor.css | 21 + .../LinearIssueMarkdownDescriptionEditor.tsx | 26 +- ...nPreview.link-routing.interaction.test.tsx | 109 +++- ...rkdownPreview.toc-visibility-gate.test.tsx | 8 +- .../src/components/editor/MarkdownPreview.tsx | 108 +++- .../components/editor/RichMarkdownEditor.tsx | 37 +- .../editor/RichMarkdownEditorSurface.tsx | 12 + .../editor/RichMarkdownLinkBubble.tsx | 130 +++- .../editor/RichMarkdownSearchBar.tsx | 38 +- .../html-character-reference-decoder.ts | 22 + .../isolated-markdown-extension-for-tests.ts | 9 + .../editor/markdown-dirty-state.test.ts | 4 +- .../editor/markdown-internal-links.ts | 96 +-- .../editor/markdown-preview-links.test.ts | 35 +- .../editor/markdown-preview-links.ts | 9 +- .../editor/markdown-round-trip.test.ts | 17 +- .../components/editor/markdown-round-trip.ts | 26 +- .../components/editor/raw-markdown-html.ts | 328 +++++----- .../editor/rich-markdown-citation-keyboard.ts | 40 ++ .../rich-markdown-clipboard-serialization.ts | 17 + .../editor/rich-markdown-clipboard-write.ts | 37 ++ .../editor/rich-markdown-commands.test.ts | 3 +- .../editor/rich-markdown-cut-handler.ts | 28 +- .../editor/rich-markdown-cut.test.ts | 50 +- .../rich-markdown-details-keyboard.test.ts | 3 +- .../editor/rich-markdown-doc-link-dom.ts | 20 + .../editor/rich-markdown-doc-link.ts | 462 +++++++------- .../rich-markdown-editor-click-routing.ts | 45 +- .../rich-markdown-editor-config.test.ts | 10 + .../editor/rich-markdown-editor-config.ts | 43 +- ...ch-markdown-empty-paragraph-delete.test.ts | 4 +- .../editor/rich-markdown-extensions.ts | 35 +- ...-markdown-html-superscript-link-context.ts | 73 +++ ...h-markdown-html-superscript-link-source.ts | 293 +++++++++ ...ich-markdown-html-superscript-link.test.ts | 593 ++++++++++++++++++ .../rich-markdown-html-superscript-link.ts | 218 +++++++ .../editor/rich-markdown-key-handler.test.ts | 16 +- .../editor/rich-markdown-key-handler.ts | 53 +- .../editor/rich-markdown-large-text-paste.ts | 6 +- .../rich-markdown-link-clipboard.test.ts | 42 ++ .../editor/rich-markdown-link-clipboard.ts | 18 + .../rich-markdown-link-shortcut.test.ts | 130 ++++ .../editor/rich-markdown-link-shortcut.ts | 64 ++ .../rich-markdown-list-continuation.test.ts | 8 +- .../editor/rich-markdown-local-image.test.ts | 3 +- .../editor/rich-markdown-normalize.test.ts | 4 +- .../editor/rich-markdown-paste-handler.ts | 40 +- .../rich-markdown-review-annotations.ts | 26 +- .../editor/rich-markdown-review-line-count.ts | 18 + .../rich-markdown-review-text-ranges.test.ts | 4 +- .../rich-markdown-review-text-ranges.ts | 81 +-- .../editor/rich-markdown-search.test.ts | 8 +- .../components/editor/rich-markdown-search.ts | 110 +++- .../rich-markdown-selected-link-actions.ts | 162 +++++ ...ich-markdown-source-owning-cut-feedback.ts | 11 + .../rich-markdown-source-owning-slice.ts | 102 +++ .../editor/rich-markdown-source-transport.ts | 86 +++ .../rich-markdown-tab-key-handler.test.ts | 30 +- .../editor/rich-markdown-visible-text-map.ts | 132 ++++ .../editor/rich-markdown-visual-line.ts | 18 +- .../components/editor/tiptap-marked-facade.ts | 82 +++ .../components/editor/useLinkBubble.test.tsx | 93 +++ .../src/components/editor/useLinkBubble.ts | 50 +- .../editor/useRichMarkdownEditorInstance.ts | 14 +- .../editor/useRichMarkdownProgrammaticSync.ts | 20 +- .../editor/useRichMarkdownSearch.ts | 13 +- .../useRichMarkdownSuperscriptLinkSetup.ts | 59 ++ .../github/GitHubMarkdownComposer.tsx | 27 +- src/renderer/src/i18n/locales/en.json | 24 +- src/renderer/src/i18n/locales/es.json | 24 +- src/renderer/src/i18n/locales/ja.json | 24 +- src/renderer/src/i18n/locales/ko.json | 24 +- src/renderer/src/i18n/locales/zh.json | 24 +- .../src/lib/connection-context.test.ts | 21 + src/renderer/src/lib/connection-context.ts | 81 +-- .../src/lib/connection-owner-resolution.ts | 100 +++ .../src/lib/http-link-routing.test.ts | 104 ++- src/renderer/src/lib/http-link-routing.ts | 47 +- src/renderer/src/store/slices/editor.test.ts | 78 ++- src/renderer/src/store/slices/editor.ts | 63 +- 82 files changed, 4301 insertions(+), 1048 deletions(-) create mode 100644 src/renderer/src/components/editor/html-character-reference-decoder.ts create mode 100644 src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-citation-keyboard.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-serialization.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-write.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link-dom.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link-context.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link-source.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-html-superscript-link.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-clipboard.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-link-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-line-count.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-selected-link-actions.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-owning-cut-feedback.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-transport.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-visible-text-map.ts create mode 100644 src/renderer/src/components/editor/tiptap-marked-facade.ts create mode 100644 src/renderer/src/components/editor/useLinkBubble.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts create mode 100644 src/renderer/src/lib/connection-owner-resolution.ts diff --git a/package.json b/package.json index 5cb55814377..473bf8ab92b 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "marked": "^17.0.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b48ad9b4f29..ebb3aa40135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -399,10 +402,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -415,10 +414,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -437,10 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,20 +440,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -487,26 +468,14 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -515,11 +484,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -577,26 +541,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -3457,6 +3409,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -4354,14 +4307,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4446,6 +4391,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4642,6 +4588,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4967,6 +4914,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5330,6 +5278,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} @@ -6740,12 +6689,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6774,14 +6717,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6792,7 +6727,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6810,26 +6745,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6840,15 +6766,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6860,7 +6777,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6869,27 +6786,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6897,10 +6808,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -6923,7 +6830,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6953,7 +6860,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) @@ -6962,30 +6869,12 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6998,11 +6887,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7127,7 +7011,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7148,9 +7032,9 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.4 + fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -8992,24 +8876,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/cacheable-request@6.0.3': dependencies: @@ -9369,8 +9253,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)': dependencies: @@ -10588,24 +10471,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -12019,7 +11889,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12089,7 +11959,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12689,7 +12558,7 @@ snapshots: shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.66.0 @@ -12703,7 +12572,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.6 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12788,7 +12657,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index ccf79ad3638..38c625f0d37 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1026,6 +1026,27 @@ color: #58a6ff; } +.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] { + line-height: 0; +} + +.rich-markdown-editor .rich-markdown-html-superscript-link { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-html-superscript-link { + color: #58a6ff; +} + +.rich-markdown-mod-held + .rich-markdown-editor + .rich-markdown-html-superscript-link[data-actionable] { + cursor: pointer; +} + .rich-markdown-editor img { max-width: 100%; border-radius: 8px; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 5e45c6e71da..f8f11c9567b 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' @@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -function createLinearIssueMarkdownExtensions() { - const extensions = createRichMarkdownExtensions() +function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { + const extensions = createRichMarkdownExtensions({ codec }) return [ ...extensions, Placeholder.configure({ @@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({ const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) + // Why: changing language recreates Tiptap and re-registers tokenizers, so it + // must also receive a fresh private Marked registry instead of growing one. + const codec = useMemo(() => { + void language + return createRichMarkdownEditorCodec() + }, [language]) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. void language - return createLinearIssueMarkdownExtensions() - }, [language]) + return createLinearIssueMarkdownExtensions(codec) + }, [codec, language]) const editor = useEditor( { immediatelyRender: false, extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), + content: encodeRawMarkdownHtmlForRichEditor(value, codec), contentType: 'markdown', editable: !disabled, editorProps: { @@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({ onChange(nextValue) } }, - [language] + [codec, language] ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) @@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({ // Why: Linear remains the source of truth when the selected issue changes // or an optimistic save is reverted; keep the rich view aligned with it. - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), { + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), { contentType: 'markdown', emitUpdate: false }) lastEditorMarkdownRef.current = value - }, [editor, value]) + }, [codec, editor, value]) return (
true) +const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined })) +const targetConnectionOwners = vi.hoisted(() => new Map()) +const worktreeLookup = vi.hoisted(() => ({ + value: [] as { id: string; path: string; diffComments: never[] }[] +})) +const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false }))) // Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful // of action functions. None of the actions fire on the http path under test. @@ -31,6 +39,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -44,14 +55,22 @@ vi.mock('@/store', () => { ) return { useAppStore } }) -vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/store/slices/worktree-helpers', () => ({ + findWorktreeById: (_worktrees: unknown, id: string) => + worktreeLookup.value.find((worktree) => worktree.id === id) ?? null +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (settings: unknown) => settings })) vi.mock('@/runtime/runtime-file-client', () => ({ - statRuntimePath: vi.fn(async () => ({ isDirectory: false })) + statRuntimePath: statRuntimePathMock +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId) +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) @@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { ;(window as unknown as { api: unknown }).api = { shell: { openUrl: openUrlMock, - openFileUri: vi.fn(), - pathExists: vi.fn(async () => true) + openFileUri: openFileUriMock, + pathExists: pathExistsMock }, ui: { writeClipboardText: vi.fn(async () => true) } } @@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { createBrowserTabMock.mockClear() setActiveWorktreeMock.mockClear() openUrlMock.mockClear() + openFileUriMock.mockClear() + pathExistsMock.mockClear() + connectionOwner.value = null + targetConnectionOwners.clear() + worktreeLookup.value = [] + storeState.worktreesByRepo = {} + storeState.openMarkdownPreview.mockClear() + statRuntimePathMock.mockClear() }) afterEach(() => { @@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { container.remove() }) - function render(): HTMLAnchorElement { + function render( + content = '[example](https://example.com)', + expectedHref = 'https://example.com', + sourceWorktreeId = 'wt-1', + filePath = '/repo/docs/README.md' + ): HTMLAnchorElement { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root.render( ) }) - const anchor = container.querySelector('a[href="https://example.com"]') + const anchor = Array.from(container.querySelectorAll('a')).find( + (candidate) => candidate.getAttribute('href') === expectedHref + ) if (!anchor) { throw new Error('expected a rendered http anchor') } @@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') expect(createBrowserTabMock).not.toHaveBeenCalled() }) + + it('keeps system-browser HTTP and file links inert while ownership is unknown', () => { + connectionOwner.value = undefined + const httpAnchor = render() + click(httpAnchor, { metaKey: true, shiftKey: true }) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + + act(() => { + root.unmount() + }) + container.remove() + const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md') + click(fileAnchor, { metaKey: true, shiftKey: true }) + expect(pathExistsMock).not.toHaveBeenCalled() + expect(openFileUriMock).not.toHaveBeenCalled() + }) + + it('keeps same-path file links on the explicit SSH source worktree', async () => { + const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] } + const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] } + worktreeLookup.value = [localWorktree, sshWorktree] + storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] } + connectionOwner.value = 'ssh-1' + targetConnectionOwners.set('local-wt', null) + targetConnectionOwners.set('ssh-wt', 'ssh-1') + const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md') + + await act(async () => { + anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(statRuntimePathMock).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }), + '/srv/repo/child.md' + ) + expect(storeState.openMarkdownPreview).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }), + { anchor: null } + ) + }) + + it('renders a raw HTML superscript citation and routes it like a Markdown link', () => { + const source = '[12]' + const anchor = render(source) + + expect(anchor.parentElement?.tagName).toBe('SUP') + expect(anchor.textContent).toBe('[12]') + expect(container.textContent).not.toContain('') + click(anchor, { metaKey: true }) + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 46301826f01..23f66fa9ee5 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -28,6 +28,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b77aaa33fb5..38d146c1312 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' @@ -68,7 +69,7 @@ import { setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' @@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot( function findWorktreeForMarkdownPreviewPath( worktreesByRepo: Record, - absolutePath: string + absolutePath: string, + acceptsWorktree: (worktree: Worktree) => boolean = () => true ): Worktree | null { let bestMatch: Worktree | null = null let bestMatchLength = -1 for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { - if (relativePathInsideRoot(worktree.path, absolutePath) !== null) { + if ( + acceptsWorktree(worktree) && + relativePathInsideRoot(worktree.path, absolutePath) !== null + ) { const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath( worktree.path ).length @@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath( return bestMatch } +function findMarkdownPreviewTargetWorktree( + worktreesByRepo: Record, + absolutePath: string, + sourceWorktree: Worktree | null, + sourceOwner: HttpLinkSourceOwner +): Worktree | null { + if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) { + return sourceWorktree + } + return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => { + const connectionId = getConnectionIdForFile(worktree.id, absolutePath) + if (sourceOwner.kind === 'local') { + return connectionId === null + } + if (sourceOwner.kind === 'ssh') { + return connectionId === sourceOwner.connectionId + } + return false + }) +} + export function resolveMarkdownPreviewSourceWorktree( worktreesByRepo: Record, sourceWorktreeId: string | null | undefined, @@ -498,9 +524,26 @@ export default function MarkdownPreview({ ) const allDiffComments = sourceWorktree?.diffComments const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId - const sourceConnectionId = sourceRoutingWorktreeId - ? (getConnectionId(sourceRoutingWorktreeId) ?? null) - : null + const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim() + const sourceConnectionIdSelector = useMemo( + () => + createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, { + skip: Boolean(runtimeOwnerId) + }), + [filePath, runtimeOwnerId, sourceRoutingWorktreeId] + ) + const sourceConnectionId = useAppStore(sourceConnectionIdSelector) + const sourceOwner = useMemo( + () => + runtimeOwnerId + ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId } + : sourceConnectionId === undefined + ? { kind: 'unknown' } + : sourceConnectionId === null + ? { kind: 'local' } + : { kind: 'ssh', connectionId: sourceConnectionId }, + [runtimeOwnerId, sourceConnectionId] + ) const worktreeRoot = sourceWorktree?.path ?? (sourceRoutingWorktreeId @@ -1254,6 +1297,9 @@ export default function MarkdownPreview({ // dangling in-worktree .md, pre-check existence so the user sees a // toast instead of the silent no-op from shell.openFileUri. if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { + if (sourceOwner.kind === 'unknown') { + return + } const osTarget = getMarkdownPreviewLinkTarget(href, filePath) if (!osTarget) { return @@ -1267,7 +1313,12 @@ export default function MarkdownPreview({ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openHttpLink( parsed.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1326,7 +1377,12 @@ export default function MarkdownPreview({ // handled above; this path only sees non-escape-hatch clicks.) openHttpLink( target.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1347,12 +1403,25 @@ export default function MarkdownPreview({ ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } : parseLineTarget(target.hash) + // Why: same-file anchors need no ownership/filesystem resolution (e.g. + // `./README.md#heading` when this file is README.md). Run before the + // unknown-ownership guard so ambiguous folder-workspace ownership still + // scrolls within the open document. if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } - const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (sourceOwner.kind === 'unknown') { + return + } + + const targetWorktree = findMarkdownPreviewTargetWorktree( + worktreesByRepo, + absolutePath, + sourceWorktree, + sourceOwner + ) if (!targetWorktree) { if (sourceRoutingWorktreeId && worktreeRoot) { // Why: floating markdown files are owned by a synthetic workspace, @@ -1362,7 +1431,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) return } @@ -1384,8 +1454,15 @@ export default function MarkdownPreview({ return } - const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath) + if (relativePath === null) { + return + } const language = detectLanguage(absolutePath) + const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath) + if (targetConnectionId === undefined) { + return + } try { const stats = await statRuntimePath( { @@ -1395,7 +1472,7 @@ export default function MarkdownPreview({ ), worktreeId: targetWorktree.id, worktreePath: targetWorktree.path, - connectionId: getConnectionId(targetWorktree.id) ?? undefined + connectionId: targetConnectionId ?? undefined }, absolutePath ) @@ -1512,7 +1589,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) } @@ -1673,6 +1751,8 @@ export default function MarkdownPreview({ setMarkdownViewMode, setPendingEditorReveal, sourceConnectionId, + sourceOwner, + sourceWorktree, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot, diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c5954052906..1c0cb2734d2 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { Editor } from '@tiptap/react' +import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { useLocalImagePick } from './useLocalImagePick' import { useRichMarkdownSearch } from './useRichMarkdownSearch' import type { LinkBubbleState } from './RichMarkdownLinkBubble' @@ -25,6 +24,11 @@ import { runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' +import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup' +import { + formatSelectedHtmlSuperscriptLinkStatus, + getSelectedHtmlSuperscriptLinkStatus +} from './rich-markdown-selected-link-actions' type RichMarkdownEditorProps = { fileId: string @@ -86,9 +90,11 @@ export default function RichMarkdownEditor({ const allDiffComments = useAppStore((s): DiffComment[] | undefined => selectWorktreeDiffComments(s, worktreeId) ) - const worktreeRoot = useAppStore( - (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null - ) + const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({ + filePath, + runtimeEnvironmentId, + worktreeId + }) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') @@ -196,6 +202,8 @@ export default function RichMarkdownEditor({ ) const editor = useRichMarkdownEditorInstance({ + codec, + htmlSuperscriptLinkContext, content, filePath, worktreeId, @@ -240,6 +248,13 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only + // citation NodeSelections would leave aria status stale without useEditorState. + const selectedCitationStatus = useEditorState({ + editor, + selector: (snapshot) => + getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext) + }) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization @@ -265,6 +280,7 @@ export default function RichMarkdownEditor({ }) useRichMarkdownProgrammaticSync({ + codec, content, docLinkMenuSetter: menu.setDocLinkMenu, editor, @@ -289,12 +305,14 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, toggleLinkFromToolbar } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, worktreeRoot, - runtimeEnvironmentId + runtimeEnvironmentId, + htmlSuperscriptLinkContext }) useEffect(() => { @@ -370,6 +388,12 @@ export default function RichMarkdownEditor({ showTableOfContents={showTableOfContents} searchState={searchState} searchActions={searchActions} + citationStatus={ + selectedCitationStatus + ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus) + : '' + } + linkBubbleOwnerId={codec.transport.key} linkBubbleActions={{ dismissLinkBubble: () => { setLinkBubble(null) @@ -379,6 +403,7 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, setIsEditingLink }} onToggleLink={toggleLinkFromToolbar} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index f5283616abb..fd619247c2a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = { matchCase: boolean matchCount: number replaceQuery: string + replaceDisabled: boolean searchQuery: string searchInputRef: React.RefObject wholeWord: boolean @@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = { toggleReplaceMode: () => void toggleWholeWord: () => void } + citationStatus: string + linkBubbleOwnerId: string linkBubbleActions: { dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void handleLinkOpen: () => void + handleLinkCopy: () => void setIsEditingLink: (editing: boolean) => void } onToggleLink: () => void @@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({ showTableOfContents, searchState, searchActions, + citationStatus, + linkBubbleOwnerId, linkBubbleActions, onToggleLink, onImagePick, @@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({ matchCount={searchState.matchCount} query={searchState.searchQuery} replaceQuery={searchState.replaceQuery} + replaceDisabled={searchState.replaceDisabled} searchInputRef={searchState.searchInputRef} wholeWord={searchState.wholeWord} onClose={searchActions.closeSearch} @@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({ onEditStart={() => linkBubbleActions.setIsEditingLink(true)} onEditCancel={linkBubbleActions.handleLinkEditCancel} onOpen={linkBubbleActions.handleLinkOpen} + onCopy={linkBubbleActions.handleLinkCopy} + ownerId={linkBubbleOwnerId} /> ) : null} + + {citationStatus} + {slashMenu ? ( void onEditCancel: () => void onOpen: () => void + onCopy: () => void + ownerId?: string portalToDocument?: boolean } @@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({ onEditStart, onEditCancel, onOpen, + onCopy, + ownerId, portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { const bubbleRef = useRef(null) @@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
{ // Prevent editor blur when clicking bubble buttons, but let inputs @@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({ e.preventDefault() } }} - onKeyDown={(e) => e.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Escape') { + event.preventDefault() + onDismiss() + anchorElement?.querySelector('[contenteditable="true"]')?.focus() + return + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + const buttons = Array.from( + bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? [] + ) + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (currentIndex === -1 || buttons.length === 0) { + return + } + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus() + }} > {isEditing ? ( @@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({ {linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href} - - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({ // actions must portal to the app layer to remain above the right sidebar. return portalToDocument ? createPortal(bubble, document.body) : bubble } + +function LinkBubbleAction({ + children, + disabled = false, + label, + onClick +}: { + children: React.ReactNode + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index ca7fd042a83..9ea8d17e068 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = { matchCount: number query: string replaceQuery: string + replaceDisabled: boolean searchInputRef: React.RefObject wholeWord: boolean onClose: () => void @@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({ matchCount, query, replaceQuery, + replaceDisabled, searchInputRef, wholeWord, onClose, @@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({ // Why: surface the same replace shortcut the source editor uses so the toggle // is discoverable; reads the user's effective binding, formatted per platform. const replaceShortcut = useOptionalShortcutLabel('editor.replace') + const readOnlyExplanationId = React.useId() if (!isOpen) { return null @@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({ } const noMatches = matchCount === 0 + const readOnlyReplaceExplanation = translate( + 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly', + 'Preserved rich content is read-only in rich mode.' + ) const toggleReplaceLabel = isReplaceMode ? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace') : translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace') @@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({ 'auto.components.editor.RichMarkdownSearchBar.44682b4159', 'Replace in rich markdown editor' )} + aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined} />
+ {replaceDisabled ? ( + + {readOnlyReplaceExplanation} + + ) : null}
) : null}
diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts new file mode 100644 index 00000000000..e8bd9d1689d --- /dev/null +++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts @@ -0,0 +1,22 @@ +export type HtmlAttributeQuote = '"' | "'" | null + +export function decodeHtmlTextCharacterReferences(value: string): string { + // Why: a