From 8a4ba4bee12e35111b896e201f87af6eab1df71c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 18 May 2026 21:08:02 -0700 Subject: [PATCH 01/20] fix: support rich markdown diff review (#2302) --- .../src/components/editor/EditorPanel.tsx | 39 ++++++++++--------- .../components/editor/EditorPanelHeader.tsx | 4 +- .../components/editor/EditorPanelShell.tsx | 2 +- .../editor/markdown-preview-controls.test.ts | 4 +- .../editor/markdown-preview-controls.ts | 8 ++-- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 053d0614c9c..237e5c76cb1 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -174,24 +174,6 @@ function EditorPanelInner({ ) useEditorCmdSaveRequest({ activeFile, openFiles, fileContents, handleSave }) - const handleEditorToggleChange = useCallback( - (next: EditorToggleValue): void => { - const fileId = activeFile?.id - if (!fileId) { - return - } - if (next === 'changes') { - setEditorViewMode(fileId, 'changes') - return - } - setEditorViewMode(fileId, 'edit') - if (next !== 'edit') { - setMarkdownViewMode(fileId, next) - } - }, - [activeFile?.id, setEditorViewMode, setMarkdownViewMode] - ) - const handleCopyPath = useCallback(async (): Promise => { if (!activeFile) { return @@ -234,7 +216,7 @@ function EditorPanelInner({ sourceGroupId }) } - const handleOpenDiffTargetFile = (): void => { + const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => { if (!model.openFileState.canOpen) { return } @@ -246,6 +228,25 @@ function EditorPanelInner({ language: detectLanguage(activeFile.relativePath), mode: 'edit' }) + if (preferredMarkdownViewMode) { + setEditorViewMode(activeFile.filePath, 'edit') + setMarkdownViewMode(activeFile.filePath, preferredMarkdownViewMode) + } + } + const handleEditorToggleChange = (next: EditorToggleValue): void => { + const fileId = activeFile.id + if (activeFile.mode === 'diff' && model.isMarkdown && next === 'rich') { + handleOpenDiffTargetFile('rich') + return + } + if (next === 'changes') { + setEditorViewMode(fileId, 'changes') + return + } + setEditorViewMode(fileId, 'edit') + if (next !== 'edit') { + setMarkdownViewMode(fileId, next) + } } const handleOpenMarkdownPreview = (): void => { openMarkdownPreview({ diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index e9e63339b4d..3034e237883 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -61,7 +61,7 @@ type EditorPanelHeaderProps = { sideBySide: boolean openFileState: EditorHeaderOpenFileState onCopyPath: () => void - onOpenDiffTargetFile: () => void + onOpenDiffTargetFile: (preferredMarkdownViewMode?: 'rich') => void onOpenPreviewToSide: () => void onOpenMarkdownPreview: () => void onOpenContainingFolder: () => void @@ -187,7 +187,7 @@ export function EditorPanelHeader({ + + + Refresh connection addresses + + +
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index cb778692247..a737a5f349b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -53,6 +53,7 @@ import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' +import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure' @@ -60,6 +61,7 @@ import { isSyntheticSinglePaneTitle, sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' +import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' @@ -587,6 +589,8 @@ export default function TerminalPane({ // so the sidebar doesn't show a stale countdown for a pane that no // longer exists. The closeTab path handles bulk cleanup, but closing // a single split pane doesn't go through closeTab. + const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null + closeWebRuntimeTerminal(ptyId) const leafId = manager.getLeafId(paneId) if (leafId) { useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) @@ -699,6 +703,69 @@ export default function TerminalPane({ setPaneCount }) + useEffect(() => { + if (!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) { + return + } + const manager = managerRef.current + if (!manager || !restoredLayout.root) { + return + } + const insertions = planTerminalLiveLayoutInsertions( + restoredLayout.root, + manager.getPanes().map((pane) => pane.leafId) + ) + if (insertions.length === 0) { + return + } + + let appliedInsertion = false + for (const insertion of insertions) { + const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId] + const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) + if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) { + continue + } + // Why: paired web terminals receive host split-pane snapshots after the + // pane manager is already mounted. Adopt the host leaf + PTY instead of + // spawning a local-only web pane. + // Before-placement swaps [source, new] after splitPane, so invert the + // host first-child ratio before applying it to the temporary order. + const splitRatio = + insertion.ratio === undefined + ? undefined + : insertion.placement === 'before' + ? 1 - insertion.ratio + : insertion.ratio + const createdPane = manager.splitPaneAroundLeafIds( + insertion.sourceLeafIds, + sourcePaneId, + insertion.direction, + { + ...(splitRatio !== undefined && { ratio: splitRatio }), + leafId: insertion.newLeafId, + ptyId, + placement: insertion.placement + } + ) + if (!createdPane) { + continue + } + appliedInsertion = true + } + + if (appliedInsertion) { + persistLayoutSnapshot() + } + + if (restoredLayout.activeLeafId) { + const activePaneId = manager.getNumericIdForLeaf(restoredLayout.activeLeafId) + if (activePaneId !== null) { + manager.setActivePane(activePaneId, { focus: isActive }) + } + } + }, [isActive, paneCount, persistLayoutSnapshot, restoredLayout]) + // Why (Activity-only pane isolation): when this TerminalPane is being // portaled into the Activity page for a specific agent pane, hide the // other split siblings so the user only sees that agent's pane. Uses 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 4c36c12db36..7b7b209db72 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -376,6 +376,26 @@ describe('connectPanePty', () => { logSpy.mockRestore() }) + it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true) + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).not.toHaveBeenCalled() + }) + it('does not send startup command via sendInput for local connections', async () => { // Why: the local PTY provider already writes the command via // writeStartupCommandWhenShellReady — sending it again from the renderer 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 765eb6fec5a..5af58efc465 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 @@ -661,7 +661,7 @@ describe('createRemoteRuntimePtyTransport', () => { } }) - it('normalizes bare LF input to carriage returns before writing to the remote PTY', async () => { + it('preserves literal LF input when sending remote PTY binary frames', async () => { vi.useFakeTimers() try { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') @@ -673,15 +673,18 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) const { streamId } = latestSubscribePayload() + runtimeCall.mockClear() subscriptionSendBinary.mockClear() expect(transport.sendInput('echo one\necho two\r\n')).toBe(true) await vi.runOnlyPendingTimersAsync() + expect(runtimeCall).not.toHaveBeenCalled() + expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) expect(frame?.streamId).toBe(streamId) - expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\recho two\r') + expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\necho two\r\n') } finally { vi.useRealTimers() } 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 087f2b41cce..8cdcd8f7017 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 @@ -30,12 +30,6 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 const HOST_SESSION_ATTACH_POLL_MS = 150 const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 -function normalizeRemoteTerminalInput(data: string): string { - // Why: PTYs expect the Enter key as carriage return. Some browser/mobile - // input paths can emit bare LF, which zsh renders with PROMPT_SP `%` marks. - return data.replace(/\r\n/g, '\r').replace(/\n/g, '\r') -} - function isRemoteTerminalGoneMessage(message: string): boolean { return ( message.includes('terminal_handle_stale') || @@ -455,13 +449,12 @@ export function createRemoteRuntimePtyTransport( if (!connected || !handle) { return false } - const normalized = normalizeRemoteTerminalInput(data) - if (!normalized) { + if (!data) { return true } - // Why: remote terminal input currently crosses the runtime RPC boundary; - // coalescing same-frame key bursts avoids a per-keystroke remote round-trip. - inputBatcher.push(normalized) + // Why: callers use \r or terminal.send's enter flag for semantic Enter; + // literal LF bytes from paste/programmatic input must survive the stream. + inputBatcher.push(data) return true }, diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts new file mode 100644 index 00000000000..f67ca0fc697 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation' +import type { TerminalPaneLayoutNode } from '../../../../shared/types' + +describe('planTerminalLiveLayoutInsertions', () => { + it('plans a host-added split leaf from an already-mounted source leaf', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([ + { + sourceLeafId: 'leaf-a', + sourceLeafIds: ['leaf-a'], + newLeafId: 'leaf-b', + direction: 'vertical', + placement: 'after' + } + ]) + }) + + it('plans nested missing leaves in the order splitPane can apply them', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([ + { + sourceLeafId: 'leaf-a', + sourceLeafIds: ['leaf-a'], + newLeafId: 'leaf-b', + direction: 'vertical', + placement: 'after' + }, + { + sourceLeafId: 'leaf-b', + sourceLeafIds: ['leaf-b'], + newLeafId: 'leaf-c', + direction: 'horizontal', + placement: 'after' + } + ]) + }) + + it('bridges a missing parent second subtree before filling the first subtree', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([ + { + sourceLeafId: 'leaf-a', + sourceLeafIds: ['leaf-a'], + newLeafId: 'leaf-c', + direction: 'vertical', + placement: 'after' + }, + { + sourceLeafId: 'leaf-a', + sourceLeafIds: ['leaf-a'], + newLeafId: 'leaf-b', + direction: 'horizontal', + placement: 'after' + } + ]) + }) + + it('plans a parent sibling after an already-mounted first-side split with host ratio', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + ratio: 0.35, + first: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a', 'leaf-b'])).toEqual([ + { + sourceLeafId: 'leaf-b', + sourceLeafIds: ['leaf-a', 'leaf-b'], + newLeafId: 'leaf-c', + direction: 'vertical', + placement: 'after', + ratio: 0.35 + } + ]) + }) + + it('plans a missing first subtree before an already-mounted second leaf', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b'])).toEqual([ + { + sourceLeafId: 'leaf-b', + sourceLeafIds: ['leaf-b'], + newLeafId: 'leaf-a', + direction: 'vertical', + placement: 'before' + } + ]) + }) + + it('plans nested missing first subtrees from an anchor in the second subtree', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-c'])).toEqual([ + { + sourceLeafId: 'leaf-c', + sourceLeafIds: ['leaf-c'], + newLeafId: 'leaf-a', + direction: 'vertical', + placement: 'before' + }, + { + sourceLeafId: 'leaf-c', + sourceLeafIds: ['leaf-c'], + newLeafId: 'leaf-b', + direction: 'horizontal', + placement: 'before' + } + ]) + }) + + it('plans a parent sibling before an already-mounted second-side split with host ratio', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + } + + expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b', 'leaf-c'])).toEqual([ + { + sourceLeafId: 'leaf-b', + sourceLeafIds: ['leaf-b', 'leaf-c'], + newLeafId: 'leaf-a', + direction: 'vertical', + placement: 'before', + ratio: 0.25 + } + ]) + }) + + it('does not plan insertions when the layout has no mounted anchor leaf', () => { + const layout: TerminalPaneLayoutNode = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + } + + expect(planTerminalLiveLayoutInsertions(layout, [])).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts new file mode 100644 index 00000000000..6173e803d93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -0,0 +1,135 @@ +import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types' + +export type TerminalLiveLayoutInsertion = { + sourceLeafId: string + sourceLeafIds: string[] + newLeafId: string + direction: TerminalPaneSplitDirection + placement: 'before' | 'after' + ratio?: number +} + +function leftmostLeafId(node: TerminalPaneLayoutNode): string { + return node.type === 'leaf' ? node.leafId : leftmostLeafId(node.first) +} + +function rightmostMountedLeafId( + node: TerminalPaneLayoutNode, + mountedLeafIds: ReadonlySet +): string | null { + if (node.type === 'leaf') { + return mountedLeafIds.has(node.leafId) ? node.leafId : null + } + return ( + rightmostMountedLeafId(node.second, mountedLeafIds) ?? + rightmostMountedLeafId(node.first, mountedLeafIds) + ) +} + +function leftmostMountedLeafId( + node: TerminalPaneLayoutNode, + mountedLeafIds: ReadonlySet +): string | null { + if (node.type === 'leaf') { + return mountedLeafIds.has(node.leafId) ? node.leafId : null + } + return ( + leftmostMountedLeafId(node.first, mountedLeafIds) ?? + leftmostMountedLeafId(node.second, mountedLeafIds) + ) +} + +function hasMountedLeaf( + node: TerminalPaneLayoutNode, + mountedLeafIds: ReadonlySet +): boolean { + if (node.type === 'leaf') { + return mountedLeafIds.has(node.leafId) + } + return hasMountedLeaf(node.first, mountedLeafIds) || hasMountedLeaf(node.second, mountedLeafIds) +} + +function mountedLeafIdsIn( + node: TerminalPaneLayoutNode, + mountedLeafIds: ReadonlySet +): string[] { + if (node.type === 'leaf') { + return mountedLeafIds.has(node.leafId) ? [node.leafId] : [] + } + return [ + ...mountedLeafIdsIn(node.first, mountedLeafIds), + ...mountedLeafIdsIn(node.second, mountedLeafIds) + ] +} + +export function planTerminalLiveLayoutInsertions( + root: TerminalPaneLayoutNode | null | undefined, + currentLeafIds: Iterable +): TerminalLiveLayoutInsertion[] { + if (!root) { + return [] + } + + const mountedLeafIds = new Set(currentLeafIds) + const insertions: TerminalLiveLayoutInsertion[] = [] + + const ensureSubtree = (node: TerminalPaneLayoutNode): boolean => { + if (node.type === 'leaf') { + return mountedLeafIds.has(node.leafId) + } + + const firstHasMounted = hasMountedLeaf(node.first, mountedLeafIds) + const secondHasMounted = hasMountedLeaf(node.second, mountedLeafIds) + if (!firstHasMounted && !secondHasMounted) { + return false + } + + // Why: bridge the current split before filling nested descendants; once a + // child subtree is split internally, PaneManager can no longer wrap it as + // this split's sibling using a leaf-only splitPane call. + if (firstHasMounted && !secondHasMounted) { + const sourceLeafId = rightmostMountedLeafId(node.first, mountedLeafIds) + const newLeafId = leftmostLeafId(node.second) + if (sourceLeafId && !mountedLeafIds.has(newLeafId)) { + insertions.push({ + sourceLeafId, + sourceLeafIds: mountedLeafIdsIn(node.first, mountedLeafIds), + newLeafId, + direction: node.direction, + placement: 'after', + ratio: node.ratio + }) + mountedLeafIds.add(newLeafId) + } + ensureSubtree(node.second) + ensureSubtree(node.first) + return true + } + + if (!firstHasMounted && secondHasMounted) { + const sourceLeafId = leftmostMountedLeafId(node.second, mountedLeafIds) + const newLeafId = leftmostLeafId(node.first) + if (sourceLeafId && !mountedLeafIds.has(newLeafId)) { + insertions.push({ + sourceLeafId, + sourceLeafIds: mountedLeafIdsIn(node.second, mountedLeafIds), + newLeafId, + direction: node.direction, + placement: 'before', + ratio: node.ratio + }) + mountedLeafIds.add(newLeafId) + } + ensureSubtree(node.first) + ensureSubtree(node.second) + return true + } + + ensureSubtree(node.first) + ensureSubtree(node.second) + return true + } + + ensureSubtree(root) + return insertions +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index a4d5c256602..8c7400939da 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { shouldDetachPaneTransportOnUnmount, - splitPaneWithOneShotStartup + splitPaneWithOneShotStartup, + suppressIntentionalPaneCloseExit } from './use-terminal-pane-lifecycle' describe('splitPaneWithOneShotStartup', () => { @@ -123,3 +124,25 @@ describe('shouldDetachPaneTransportOnUnmount', () => { ).toBe(false) }) }) + +describe('suppressIntentionalPaneCloseExit', () => { + it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => { + const suppressPtyExit = vi.fn() + const transport = { + getPtyId: vi.fn(() => 'pty-pane-2') + } + + expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBe('pty-pane-2') + expect(suppressPtyExit).toHaveBeenCalledWith('pty-pane-2') + }) + + it('does not suppress natural PTY exits that already cleared the transport id', () => { + const suppressPtyExit = vi.fn() + const transport = { + getPtyId: vi.fn(() => null) + } + + expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBeNull() + expect(suppressPtyExit).not.toHaveBeenCalled() + }) +}) 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 2c89fb2bdd3..c84cded8ef7 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 @@ -141,6 +141,17 @@ type UseTerminalPaneLifecycleDeps = { setPaneCount: React.Dispatch> } +export function suppressIntentionalPaneCloseExit( + transport: Pick | null | undefined, + suppressPtyExit: (ptyId: string) => void +): string | null { + const ptyId = transport?.getPtyId() ?? null + if (ptyId) { + suppressPtyExit(ptyId) + } + return ptyId +} + function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean { const range = terminal.getSelectionPosition() if (!range) { @@ -681,8 +692,14 @@ export function useTerminalPaneLifecycle({ panePtyBindings.delete(paneId) } if (transport) { - const ptyId = transport.getPtyId() + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. syncPanePtyLayoutBinding(paneId, null) clearTabPtyId(tabId, ptyId) } diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 3508237ad1c..fc0e32573de 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -27,7 +27,11 @@ vi.mock('@/runtime/web-runtime-session', () => ({ isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock })) -import { createNewTerminalTab } from './terminal-tab-actions' +import { + closeOtherTerminalTabs, + closeTerminalTabsToRight, + createNewTerminalTab +} from './terminal-tab-actions' describe('createNewTerminalTab', () => { beforeEach(() => { @@ -83,3 +87,82 @@ describe('createNewTerminalTab', () => { expect(setActiveTabType).not.toHaveBeenCalled() }) }) + +describe('closeOtherTerminalTabs', () => { + beforeEach(() => { + vi.clearAllMocks() + isWebRuntimeSessionActiveMock.mockReturnValue(false) + }) + + it('delegates other terminal closes to the host runtime in paired web clients', () => { + const setActiveTab = vi.fn() + const closeTab = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'keep' }, { id: 'close-a' }, { id: 'close-b' }] + }, + setActiveTab, + closeTab + }) + + closeOtherTerminalTabs('keep', 'wt-1') + + expect(setActiveTab).toHaveBeenCalledWith('keep') + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'close-a', + environmentId: 'web-runtime' + }) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'close-b', + environmentId: 'web-runtime' + }) + expect(closeTab).not.toHaveBeenCalled() + }) +}) + +describe('closeTerminalTabsToRight', () => { + beforeEach(() => { + vi.clearAllMocks() + isWebRuntimeSessionActiveMock.mockReturnValue(false) + }) + + it('delegates terminal tabs to the host while still closing local editor tabs to the right', () => { + const closeTab = vi.fn() + const closeFile = vi.fn() + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock + .mockReturnValueOnce({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + tabsByWorktree: { + 'wt-1': [{ id: 'term-a' }, { id: 'term-b' }, { id: 'term-c' }] + }, + openFiles: [{ id: 'file-b', worktreeId: 'wt-1' }], + tabBarOrderByWorktree: { 'wt-1': ['term-a', 'file-b', 'term-b', 'term-c'] }, + closeTab + }) + .mockReturnValue({ + closeFile + }) + + closeTerminalTabsToRight('term-a', 'wt-1') + + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'term-b', + environmentId: 'web-runtime' + }) + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'term-c', + environmentId: 'web-runtime' + }) + expect(closeFile).toHaveBeenCalledWith('file-b') + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index ce18bd9728a..64aa47d59d0 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -109,9 +109,21 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | const state = useAppStore.getState() const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? [] state.setActiveTab(tabId) + const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) for (const tab of currentTabs) { if (tab.id !== tabId) { - state.closeTab(tab.id) + if (closeHostTerminalTabs) { + // Why: paired web tabs are host-owned; local-only bulk close leaves + // the host to re-publish the supposedly closed terminal tabs. + void closeWebRuntimeSessionTab({ + worktreeId: activeWorktreeId, + tabId: tab.id, + environmentId: runtimeEnvironmentId + }) + } else { + state.closeTab(tab.id) + } } } } @@ -124,6 +136,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string const state = useAppStore.getState() const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) + const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) const terminalIds = currentTerminalTabs.map((t) => t.id) const terminalIdSet = new Set(terminalIds) const orderedIds = reconcileTabOrder( @@ -139,7 +153,17 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string const rightIds = orderedIds.slice(index + 1) for (const id of rightIds) { if (terminalIdSet.has(id)) { - state.closeTab(id) + if (closeHostTerminalTabs) { + // Why: paired web tabs are host-owned; local-only bulk close leaves + // the host to re-publish the supposedly closed terminal tabs. + void closeWebRuntimeSessionTab({ + worktreeId: activeWorktreeId, + tabId: id, + environmentId: runtimeEnvironmentId + }) + } else { + state.closeTab(id) + } } else { useAppStore.getState().closeFile(id) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 8f356ef14c0..7828043134d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,6 +5,7 @@ import type { ManagedPaneInternal, DropZone } from './pane-manager-types' +import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { createDivider, applyDividerStyles, @@ -32,6 +33,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, splitManagedPane } from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' +import { splitPaneAroundMountedSubtree } from './pane-subtree-split' export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } @@ -100,6 +102,33 @@ export class PaneManager { }) } + splitPaneAroundLeafIds( + sourceLeafIds: readonly string[], + fallbackPaneId: number, + direction: 'vertical' | 'horizontal', + opts?: SplitPaneAroundLeafIdsOptions + ): ManagedPane | null { + return splitPaneAroundMountedSubtree({ + sourceLeafIds, + fallbackPaneId, + direction, + opts, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getNumericIdForLeaf: (leafId) => this.identities.getNumericIdForLeaf(leafId), + createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint), + createDivider: (isVertical) => this.createDividerWrapped(isVertical), + publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints), + getDragCallbacks: () => this.getDragCallbacks(), + setActivePaneId: (id) => { + this.activePaneId = id + }, + isDestroyed: () => this.destroyed + }) + } + closePane(paneId: number): void { closeManagedPane({ paneId, diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts new file mode 100644 index 00000000000..93ef7198d31 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' + +const captureScrollState = vi.hoisted(() => vi.fn()) +const wrapInSplit = vi.hoisted(() => vi.fn()) +const openTerminal = vi.hoisted(() => vi.fn()) +const disposeWebgl = vi.hoisted(() => vi.fn()) +const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn()) +const updateMultiPaneState = vi.hoisted(() => vi.fn()) +const applyPaneOpacity = vi.hoisted(() => vi.fn()) +const applyDividerStyles = vi.hoisted(() => vi.fn()) + +vi.mock('./pane-tree-ops', () => ({ + captureScrollState, + findPaneChildren: vi.fn(), + promoteSibling: vi.fn(), + removeDividers: vi.fn(), + safeFit: vi.fn(), + wrapInSplit +})) + +vi.mock('./pane-lifecycle', () => ({ + disposePane: vi.fn(), + openTerminal +})) + +vi.mock('./pane-webgl-renderer', () => ({ + disposeWebgl +})) + +vi.mock('./pane-split-scroll', () => ({ + scheduleSplitScrollRestore +})) + +vi.mock('./pane-drag-reorder', () => ({ + updateMultiPaneState +})) + +vi.mock('./pane-divider', () => ({ + applyDividerStyles, + applyPaneOpacity +})) + +import { splitManagedPane } from './pane-split-close' + +const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + +class MockElement { + classList: { contains: (className: string) => boolean } + dataset: Record = {} + parentElement: MockElement | null = null + style: Record = {} + private descendants: MockElement[] = [] + + constructor(private readonly classNames: string[]) { + this.classList = { + contains: (className: string) => this.classNames.includes(className) + } + } + + setQuerySelectorAllResult(descendants: MockElement[]): void { + this.descendants = descendants + } + + querySelectorAll(): MockElement[] { + return this.descendants + } +} + +function createScrollState(viewportY: number): ScrollState { + return { + bufferType: 'normal', + wasAtBottom: false, + firstVisibleLineContent: `line-${viewportY}`, + viewportY, + totalLines: 100 + } +} + +function createPane(id: number, webglAddon: unknown): ManagedPaneInternal { + const container = new MockElement(['pane']) + container.dataset.paneId = String(id) + container.dataset.leafId = TEST_LEAF_ID + return { + id, + leafId: TEST_LEAF_ID, + stablePaneId: TEST_LEAF_ID, + terminal: { + focus: vi.fn() + } as never, + container: container as unknown as HTMLElement, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'auto', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: webglAddon as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('splitManagedPane', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prepares every pane under a moved mounted subtree for split reparenting', () => { + const fallbackPane = createPane(1, { dispose: vi.fn() }) + const siblingPane = createPane(2, { dispose: vi.fn() }) + const newPane = createPane(3, null) + const panes = new Map([ + [fallbackPane.id, fallbackPane], + [siblingPane.id, siblingPane] + ]) + const root = new MockElement(['root']) + const sourceContainer = new MockElement(['pane-split']) + sourceContainer.parentElement = root + sourceContainer.setQuerySelectorAllResult([ + fallbackPane.container as unknown as MockElement, + siblingPane.container as unknown as MockElement + ]) + const fallbackScrollState = createScrollState(11) + const siblingScrollState = createScrollState(22) + captureScrollState + .mockReturnValueOnce(fallbackScrollState) + .mockReturnValueOnce(siblingScrollState) + + const result = splitManagedPane({ + paneId: fallbackPane.id, + direction: 'vertical', + sourceContainer: sourceContainer as unknown as HTMLElement, + panes, + root: root as unknown as HTMLElement, + styleOptions: {}, + managerOptions: {}, + createPaneInternal: () => { + panes.set(newPane.id, newPane) + return newPane + }, + createDivider: () => new MockElement(['pane-divider']) as unknown as HTMLElement, + publishPaneCreated: vi.fn(), + getDragCallbacks: () => ({}) as never, + setActivePaneId: vi.fn(), + isDestroyed: () => false + }) + + expect(result?.id).toBe(newPane.id) + expect(captureScrollState).toHaveBeenCalledWith(fallbackPane.terminal) + expect(captureScrollState).toHaveBeenCalledWith(siblingPane.terminal) + expect(fallbackPane.pendingSplitScrollState).toBe(fallbackScrollState) + expect(siblingPane.pendingSplitScrollState).toBe(siblingScrollState) + expect(disposeWebgl).toHaveBeenCalledWith(fallbackPane) + expect(disposeWebgl).toHaveBeenCalledWith(siblingPane) + expect(wrapInSplit).toHaveBeenCalledWith( + sourceContainer, + newPane.container, + true, + expect.anything(), + undefined + ) + expect(scheduleSplitScrollRestore).toHaveBeenCalledTimes(2) + expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + fallbackPane.id, + fallbackScrollState, + expect.any(Function), + expect.any(Function) + ) + expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith( + 2, + expect.any(Function), + siblingPane.id, + siblingScrollState, + expect.any(Function), + expect.any(Function) + ) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 65cce00d4ca..a6fa1412ce2 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -21,10 +21,17 @@ import { scheduleSplitScrollRestore } from './pane-split-scroll' import { reattachWebglIfNeeded } from './pane-webgl-reattach' import { toPublicPane } from './pane-public-view' +type MovedPaneSplitState = { + pane: ManagedPaneInternal + scrollState: ReturnType + hadWebgl: boolean +} + type SplitManagedPaneArgs = { paneId: number direction: 'vertical' | 'horizontal' opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string } + sourceContainer?: HTMLElement panes: Map root: HTMLElement styleOptions: PaneStyleOptions @@ -45,7 +52,8 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null if (!existing) { return null } - const parent = existing.container.parentElement + const existingContainer = args.sourceContainer ?? existing.container + const parent = existingContainer.parentElement if (!parent) { return null } @@ -53,32 +61,78 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null const isVertical = args.direction === 'vertical' const divider = args.createDivider(isVertical) - // Why: wrapInSplit reparents the existing container, resetting scrollTop. - const scrollState = captureScrollState(existing.terminal) - // Why: lock prevents safeFit/fitAllPanes from restoring scroll during the - // async settle window; scheduleSplitScrollRestore owns the restore. - existing.pendingSplitScrollState = scrollState + const movedPaneStates = prepareMovedPanesForSplit(existingContainer, existing, args.panes) - // Why: DOM reparenting can silently invalidate a WebGL context without - // firing contextlost, so dispose before the move and reattach after settle. - const hadWebgl = !!existing.webglAddon - disposeWebgl(existing) - - wrapInSplit(existing.container, newPane.container, isVertical, divider, args.opts) + wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts) args.setActivePaneId(newPane.id) openSplitPane(args, newPane, args.opts?.cwd) - scheduleSplitScrollRestore( - (id) => args.panes.get(id), - existing.id, - scrollState, - args.isDestroyed, - hadWebgl ? reattachWebglIfNeeded : undefined - ) + for (const movedPaneState of movedPaneStates) { + scheduleSplitScrollRestore( + (id) => args.panes.get(id), + movedPaneState.pane.id, + movedPaneState.scrollState, + args.isDestroyed, + movedPaneState.hadWebgl ? reattachWebglIfNeeded : undefined + ) + } return toPublicPane(newPane) } +function prepareMovedPanesForSplit( + sourceContainer: HTMLElement, + fallbackPane: ManagedPaneInternal, + panes: Map +): MovedPaneSplitState[] { + const movedPanes = findManagedPanesInContainer(sourceContainer, panes) + if (movedPanes.length === 0) { + movedPanes.push(fallbackPane) + } + + return movedPanes.map((pane) => { + // Why: wrapInSplit reparents moved containers, resetting browser scrollTop. + const scrollState = captureScrollState(pane.terminal) + // Why: lock prevents safeFit/fitAllPanes from restoring scroll during the + // async settle window; scheduleSplitScrollRestore owns the restore. + pane.pendingSplitScrollState = scrollState + + // Why: DOM reparenting can silently invalidate a WebGL context without + // firing contextlost, so dispose before the move and reattach after settle. + const hadWebgl = !!pane.webglAddon + disposeWebgl(pane) + return { pane, scrollState, hadWebgl } + }) +} + +function findManagedPanesInContainer( + sourceContainer: HTMLElement, + panes: Map +): ManagedPaneInternal[] { + const movedPanes: ManagedPaneInternal[] = [] + const appendPaneById = (paneIdValue: string | undefined): void => { + if (!paneIdValue) { + return + } + const paneId = Number(paneIdValue) + if (!Number.isFinite(paneId)) { + return + } + const pane = panes.get(paneId) + if (pane && !movedPanes.includes(pane)) { + movedPanes.push(pane) + } + } + + if (sourceContainer.classList.contains('pane')) { + appendPaneById(sourceContainer.dataset.paneId) + } + for (const paneElement of sourceContainer.querySelectorAll('.pane[data-pane-id]')) { + appendPaneById(paneElement.dataset.paneId) + } + return movedPanes +} + function openSplitPane( args: SplitManagedPaneArgs, newPane: ManagedPaneInternal, diff --git a/src/renderer/src/lib/pane-manager/pane-subtree-split.ts b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts new file mode 100644 index 00000000000..37e75cfd3e6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts @@ -0,0 +1,147 @@ +import type { + ManagedPane, + ManagedPaneInternal, + PaneManagerOptions, + PaneStyleOptions +} from './pane-manager-types' +import type { DragReorderCallbacks } from './pane-drag-reorder' +import { splitManagedPane } from './pane-split-close' + +export type SplitPaneAroundLeafIdsOptions = { + ratio?: number + cwd?: string + leafId?: string + ptyId?: string + placement?: 'before' | 'after' +} + +type SplitPaneAroundLeafIdsArgs = { + sourceLeafIds: readonly string[] + fallbackPaneId: number + direction: 'vertical' | 'horizontal' + opts?: SplitPaneAroundLeafIdsOptions + panes: Map + root: HTMLElement + styleOptions: PaneStyleOptions + managerOptions: PaneManagerOptions + getNumericIdForLeaf: (leafId: string) => number | null + createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal + createDivider: (isVertical: boolean) => HTMLElement + publishPaneCreated: ( + pane: ManagedPaneInternal, + spawnHints?: Parameters>[1] + ) => void + getDragCallbacks: () => DragReorderCallbacks + setActivePaneId: (paneId: number | null) => void + isDestroyed: () => boolean +} + +export function splitPaneAroundMountedSubtree( + args: SplitPaneAroundLeafIdsArgs +): ManagedPane | null { + // Why: live host reconciliation may need to add a sibling to an already + // mounted split subtree; splitting only the anchor leaf corrupts the shape. + const sourceContainer = + findMountedSubtreeContainer(args.sourceLeafIds, args) ?? + args.panes.get(args.fallbackPaneId)?.container + if (!sourceContainer) { + return null + } + const createdPane = splitManagedPane({ + paneId: args.fallbackPaneId, + direction: args.direction, + opts: args.opts, + sourceContainer, + panes: args.panes, + root: args.root, + styleOptions: args.styleOptions, + managerOptions: args.managerOptions, + createPaneInternal: args.createPaneInternal, + createDivider: args.createDivider, + publishPaneCreated: args.publishPaneCreated, + getDragCallbacks: args.getDragCallbacks, + setActivePaneId: args.setActivePaneId, + isDestroyed: args.isDestroyed + }) + if (!createdPane || args.opts?.placement !== 'before') { + return createdPane + } + + const createdInternal = args.panes.get(createdPane.id) + if (createdInternal) { + placeCreatedPaneBeforeSource(sourceContainer, createdInternal.container) + } + return createdPane +} + +function findMountedSubtreeContainer( + sourceLeafIds: readonly string[], + args: Pick +): HTMLElement | null { + if (sourceLeafIds.length === 0) { + return null + } + const expectedLeafIds = new Set(sourceLeafIds) + const firstLeafId = sourceLeafIds[0] + if (!firstLeafId) { + return null + } + const firstPaneId = args.getNumericIdForLeaf(firstLeafId) + const firstPane = firstPaneId === null ? null : args.panes.get(firstPaneId) + let candidate: HTMLElement | null = firstPane?.container ?? null + while (candidate && candidate !== args.root) { + if ( + (candidate.classList.contains('pane') || candidate.classList.contains('pane-split')) && + setsEqual(leafIdsInContainer(candidate), expectedLeafIds) + ) { + return candidate + } + candidate = candidate.parentElement + } + return null +} + +function leafIdsInContainer(container: HTMLElement): Set { + const leafIds = new Set() + if (container.classList.contains('pane') && container.dataset.leafId) { + leafIds.add(container.dataset.leafId) + } + for (const pane of container.querySelectorAll('.pane[data-leaf-id]')) { + if (pane.dataset.leafId) { + leafIds.add(pane.dataset.leafId) + } + } + return leafIds +} + +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const value of left) { + if (!right.has(value)) { + return false + } + } + return true +} + +function placeCreatedPaneBeforeSource( + sourceContainer: HTMLElement, + createdContainer: HTMLElement +): boolean { + const split = createdContainer.parentElement + if (!split || sourceContainer.parentElement !== split) { + return false + } + const divider = Array.from(split.children).find( + (child): child is HTMLElement => + child instanceof HTMLElement && child.classList.contains('pane-divider') + ) + if (!divider) { + return false + } + + split.replaceChildren(createdContainer, divider, sourceContainer) + return true +} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 582d4af028b..0191f9cb747 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -41,6 +41,7 @@ const runtimeEnvironmentSubscribe = vi.fn() const runtimeCall = vi.fn() beforeEach(() => { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ clearRuntimeCompatibilityCacheForTests() fsReadFile.mockReset() fsOnChanged.mockReset() @@ -1183,4 +1184,76 @@ describe('runtime file client', () => { }) ) }) + + it('delegates stopped pre-ready web shared file watch cleanup to the subscription handle', async () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + const onPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const stop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + onPayload + ) + + stop() + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.unwatch' }) + ) + + onResponse?.({ + id: 'ready', + ok: true, + result: { type: 'ready', subscriptionId: 'files-watch-late' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.unwatch' }) + ) + }) + + it('delegates stopped ready web shared file watch cleanup to the subscription handle', async () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + const onPayload = vi.fn() + const unsubscribe = vi.fn() + let onResponse: ((response: unknown) => void) | undefined + runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => { + onResponse = callbacks.onResponse + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + + const stop = await subscribeRuntimeFileChanges( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + onPayload + ) + + onResponse?.({ + id: 'ready', + ok: true, + result: { type: 'ready', subscriptionId: 'files-watch-ready' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + stop() + + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.unwatch' }) + ) + }) }) diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 233f641c61b..4eafe3097e6 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -101,6 +101,7 @@ type SharedRuntimeFileWatch = { start: Promise unsubscribe: (() => void) | null remoteSubscriptionId: string | null + keepStreamUntilReady: boolean closed: boolean } @@ -635,6 +636,7 @@ function createSharedRuntimeFileWatch( start: Promise.resolve(), unsubscribe: null, remoteSubscriptionId: null, + keepStreamUntilReady: isWebRuntimeFileWatchSharedSocket(), closed: false } // Why: editor reloads and the Explorer can watch the same remote worktree. @@ -664,12 +666,14 @@ function createSharedRuntimeFileWatch( } ) .then((subscription) => { + shared.unsubscribe = subscription.unsubscribe if (shared.closed || sharedRuntimeFileWatches.get(key) !== shared) { subscription.unsubscribe() - unwatchSharedRuntimeFileWatch(shared) - return + shared.unsubscribe = null + if (!shared.keepStreamUntilReady) { + unwatchSharedRuntimeFileWatch(shared) + } } - shared.unsubscribe = subscription.unsubscribe }) .catch((err) => { if (sharedRuntimeFileWatches.get(key) === shared) { @@ -693,6 +697,13 @@ function handleSharedRuntimeFileWatchResponse( ) if (event.type === 'ready') { shared.remoteSubscriptionId = event.subscriptionId + if (shared.closed) { + shared.unsubscribe?.() + shared.unsubscribe = null + if (!shared.keepStreamUntilReady) { + unwatchSharedRuntimeFileWatch(shared) + } + } } else if (event.type === 'changed') { for (const listener of Array.from(shared.listeners)) { listener.onPayload({ worktreePath, events: event.events }) @@ -715,11 +726,22 @@ function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch } shared.closed = true sharedRuntimeFileWatches.delete(key) + if (shared.keepStreamUntilReady) { + // Why: WebRuntimeClient owns shared-socket file-watch cleanup, including + // pre-ready fallback timers and late-ready files.unwatch. + shared.unsubscribe?.() + shared.unsubscribe = null + return + } shared.unsubscribe?.() shared.unsubscribe = null unwatchSharedRuntimeFileWatch(shared) } +function isWebRuntimeFileWatchSharedSocket(): boolean { + return Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + function unwatchSharedRuntimeFileWatch(shared: SharedRuntimeFileWatch): void { if (!shared.remoteSubscriptionId) { return diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index b2f85022acb..4cd3433ceac 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { activateWebRuntimeSessionTab, + closeWebRuntimeTerminal, closeWebRuntimeSessionTab, createWebRuntimeSessionBrowserTab, createWebRuntimeSessionTerminal, @@ -667,3 +668,64 @@ describe('web runtime session tab actions', () => { }) }) }) + +describe('closeWebRuntimeTerminal', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('delegates remote pane close to the host runtime', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'close', + ok: true, + result: { + close: { + handle: 'terminal-1', + tabId: 'tab-1', + ptyKilled: true + } + } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'web-env-1', + method: 'terminal.close', + params: { + terminal: 'terminal-1' + }, + timeoutMs: 15_000 + }) + }) + + it('ignores local panes and inactive web sessions', () => { + const runtimeCall = vi.fn() + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(closeWebRuntimeTerminal('pty-local-1')).toBe(false) + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(false) + + expect(runtimeCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index f51aaab981b..224f1d0300a 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -6,6 +6,7 @@ import type { RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabsResult, + RuntimeTerminalClose, RuntimeTerminalSplit } from '../../../shared/runtime-types' import type { AppState } from '../store/types' @@ -455,3 +456,37 @@ export function splitWebRuntimeTerminal( }) return true } + +export function closeWebRuntimeTerminal(ptyId: string | null | undefined): boolean { + if (!ptyId) { + return false + } + const remote = parseRemoteRuntimePtyId(ptyId) + const environmentId = remote?.environmentId?.trim() + if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + + // Why: host-session mirror panes are detached locally in the browser, but + // the host owns the real pane graph. Close the host terminal first so later + // session snapshots cannot resurrect the locally removed pane. + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method: 'terminal.close', + params: { + terminal: remote.handle + }, + timeoutMs: 15_000 + }) + .then((response) => { + unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ close: RuntimeTerminalClose }>) + }) + .catch((error) => { + console.warn( + '[web-runtime-session] failed to close terminal pane:', + error instanceof Error ? error.message : String(error) + ) + }) + return true +} diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index dfad72ebd25..0380620a9fb 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: these tests share one mocked browser + WebSocket/E2EE transport fixture, and splitting them would obscure the + subscription lifecycle regressions they cover. */ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import WebSocket, { WebSocketServer } from 'ws' import { WebRuntimeClient } from './web-runtime-client' @@ -128,6 +131,227 @@ describe('WebRuntimeClient', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('keeps file watches on the owning WebSocket instead of opening child clients', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const handle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } + const internals = client as unknown as { + childClients: Set + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValue(handle) + + const subscription = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + + expect(subscribeOnCurrentConnection).toHaveBeenCalledWith( + 'files.watch', + { worktree: 'wt-1' }, + expect.objectContaining({ onResponse: expect.any(Function) }), + undefined + ) + expect(internals.childClients.size).toBe(0) + const frame = new Uint8Array([1]) + subscription.sendBinary(frame) + expect(handle.sendBinary).toHaveBeenCalledWith(frame) + client.close() + }) + + it('unwatches a direct file watch before removing the shared local subscription', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } + const internals = client as unknown as { + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValue(localHandle) + const unwatch = vi.spyOn(client, 'call').mockImplementation(() => { + expect(localHandle.unsubscribe).not.toHaveBeenCalled() + return Promise.resolve({ + id: 'unwatch', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'runtime-web-test' } + }) + }) + const onResponse = vi.fn() + + const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + wrappedCallbacks?.onResponse({ + id: 'watch', + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'files-watch-1' }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + + subscription.unsubscribe() + + expect(onResponse).toHaveBeenCalledTimes(1) + expect(unwatch).toHaveBeenCalledWith( + 'files.unwatch', + { subscriptionId: 'files-watch-1' }, + { timeoutMs: 5_000 } + ) + await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)) + client.close() + }) + + it('removes the shared local subscription when remote unwatch fails', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } + const internals = client as unknown as { + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValue(localHandle) + const unwatchError = new Error('remote unwatch failed') + const unwatch = vi.spyOn(client, 'call').mockRejectedValue(unwatchError) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const subscription = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + wrappedCallbacks?.onResponse({ + id: 'watch', + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'files-watch-failing-unwatch' }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + + subscription.unsubscribe() + + expect(unwatch).toHaveBeenCalledWith( + 'files.unwatch', + { subscriptionId: 'files-watch-failing-unwatch' }, + { timeoutMs: 5_000 } + ) + await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)) + expect(warn).toHaveBeenCalledWith('Failed to unwatch remote file subscription:', unwatchError) + } finally { + client.close() + warn.mockRestore() + } + }) + + it('keeps a stopped direct file watch alive until ready so it can unwatch', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } + const internals = client as unknown as { + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') + .mockResolvedValue(localHandle) + const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ + id: 'unwatch', + ok: true, + result: { unsubscribed: true }, + _meta: { runtimeId: 'runtime-web-test' } + }) + const onResponse = vi.fn() + + const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] + + subscription.unsubscribe() + expect(unwatch).not.toHaveBeenCalled() + expect(localHandle.unsubscribe).not.toHaveBeenCalled() + + wrappedCallbacks?.onResponse({ + id: 'watch', + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'files-watch-late' }, + _meta: { runtimeId: 'runtime-web-test' } + } as RuntimeRpcResponse & { streaming: true }) + + expect(onResponse).not.toHaveBeenCalled() + expect(unwatch).toHaveBeenCalledWith( + 'files.unwatch', + { subscriptionId: 'files-watch-late' }, + { timeoutMs: 5_000 } + ) + await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)) + client.close() + }) + + it('cleans up a stopped pre-ready shared file watch if ready never arrives', async () => { + vi.useFakeTimers() + const timerWindow = window as unknown as { + setTimeout: typeof setTimeout + clearTimeout: typeof clearTimeout + } + timerWindow.setTimeout = setTimeout + timerWindow.clearTimeout = clearTimeout + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } + const internals = client as unknown as { + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] + } + vi.spyOn(internals, 'subscribeOnCurrentConnection').mockResolvedValue(localHandle) + const unwatch = vi.spyOn(client, 'call') + + try { + const subscription = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + + subscription.unsubscribe() + expect(unwatch).not.toHaveBeenCalled() + expect(localHandle.unsubscribe).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(4_999) + expect(localHandle.unsubscribe).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1) + expect(unwatch).not.toHaveBeenCalled() + } finally { + client.close() + vi.useRealTimers() + } + }) + it('decrypts binary WebSocket frames into subscription callbacks', async () => { const client = new WebRuntimeClient({ v: 2, diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index fe3b2f5bb2e..22212b314a1 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: this browser runtime client owns the E2EE WebSocket state machine, JSON-RPC request routing, streaming callbacks, and binary frame forwarding as one transport boundary. */ -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import type { WebPairingOffer } from './web-pairing' import { @@ -50,7 +50,9 @@ export type WebRuntimeSubscriptionHandle = { const REQUEST_TIMEOUT_MS = 30_000 const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 10_000 +const FILE_WATCH_READY_CLEANUP_TIMEOUT_MS = 5_000 const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 15_000] +const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch']) export class WebRuntimeClient { private ws: WebSocket | null = null @@ -101,6 +103,12 @@ export class WebRuntimeClient { callbacks: SubscriptionCallbacks, options?: { timeoutMs?: number } ): Promise { + if (SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method)) { + // Why: file watches are text-only and already have an explicit + // files.unwatch RPC, so sharing the main socket avoids exhausting the + // server's WebSocket connection cap in large browser sessions. + return this.subscribeSharedFileWatch(params, callbacks, options) + } const client = new WebRuntimeClient(this.pairing) this.childClients.add(client) const closeChild = (notifySubscriptions = false): void => { @@ -138,6 +146,112 @@ export class WebRuntimeClient { } } + private async subscribeSharedFileWatch( + params: unknown, + callbacks: SubscriptionCallbacks, + options?: { timeoutMs?: number } + ): Promise { + let stopped = false + let remoteSubscriptionId: string | null = null + let unwatchStarted = false + let handle: WebRuntimeSubscriptionHandle | null = null + let readyCleanupTimer: number | null = null + const clearReadyCleanupTimer = (): void => { + if (readyCleanupTimer === null) { + return + } + window.clearTimeout(readyCleanupTimer) + readyCleanupTimer = null + } + const dropLocalSubscription = (): void => { + clearReadyCleanupTimer() + handle?.unsubscribe() + } + const schedulePreReadyCleanup = (): void => { + if (readyCleanupTimer !== null) { + return + } + // Why: if a stopped watch never reaches ready/error, no remote id exists + // to unwatch; bound the lifetime of the local callback on this socket. + readyCleanupTimer = window.setTimeout(() => { + readyCleanupTimer = null + handle?.unsubscribe() + }, FILE_WATCH_READY_CLEANUP_TIMEOUT_MS) + } + const unwatchAndDropLocalSubscription = (): void => { + if (unwatchStarted) { + return + } + unwatchStarted = true + if (!remoteSubscriptionId) { + dropLocalSubscription() + return + } + clearReadyCleanupTimer() + // Why: shared files.watch streams stay on this socket, so stop the + // server watcher before removing the local callback that receives ready. + void this.call( + 'files.unwatch', + { subscriptionId: remoteSubscriptionId }, + { timeoutMs: 5_000 } + ) + .catch((error) => { + console.warn('Failed to unwatch remote file subscription:', error) + }) + .finally(() => { + dropLocalSubscription() + }) + } + const wrappedCallbacks: SubscriptionCallbacks = { + ...callbacks, + onResponse: (response) => { + if (isFileWatchReadyResponse(response)) { + remoteSubscriptionId = response.result.subscriptionId + if (stopped) { + unwatchAndDropLocalSubscription() + return + } + } + if (!stopped) { + callbacks.onResponse(response) + } else if (response.ok === false) { + dropLocalSubscription() + } + }, + onError: (error) => { + if (!stopped) { + callbacks.onError?.(error) + } + }, + onClose: () => { + if (!stopped) { + callbacks.onClose?.() + } + } + } + handle = await this.subscribeOnCurrentConnection( + 'files.watch', + params, + wrappedCallbacks, + options + ) + + return { + unsubscribe: () => { + if (stopped) { + return + } + stopped = true + if (remoteSubscriptionId) { + unwatchAndDropLocalSubscription() + } else { + schedulePreReadyCleanup() + } + }, + sendBinary: (bytes) => handle?.sendBinary(bytes) + } + } + private async subscribeOnCurrentConnection( method: string, params: unknown, @@ -529,6 +643,21 @@ function isRuntimeFailureResponse( ) } +function isFileWatchReadyResponse( + response: RuntimeRpcResponse +): response is RuntimeRpcSuccess<{ type: 'ready'; subscriptionId: string }> { + if (!response.ok) { + return false + } + const result = response.result + return ( + !!result && + typeof result === 'object' && + (result as { type?: unknown }).type === 'ready' && + typeof (result as { subscriptionId?: unknown }).subscriptionId === 'string' + ) +} + function isEndResult(value: unknown): value is { type: 'end' } { return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end' } From f8a2f2889e5798534cc9452467ef42835f5860ea Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 18 May 2026 21:55:16 -0700 Subject: [PATCH 06/20] Improve settings organization (#2305) --- src/renderer/src/assets/main.css | 13 +- .../src/components/settings/AccountsPane.tsx | 9 +- .../src/components/settings/GeneralPane.tsx | 30 +- .../src/components/settings/Settings.tsx | 400 ++++++++++-------- .../components/settings/SettingsSection.tsx | 10 +- .../components/settings/SettingsSidebar.tsx | 81 ++-- .../components/settings/accounts-search.ts | 12 +- .../src/components/settings/general-search.ts | 8 +- .../status-bar/SshStatusSegment.tsx | 2 +- 9 files changed, 310 insertions(+), 255 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 446f2260f18..59d92760043 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -807,19 +807,18 @@ @keyframes settings-section-flash { 0% { border-color: var(--ring); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--ring) 45%, transparent), - 0 1px 2px 0 rgb(0 0 0 / 0.05); + background: color-mix(in srgb, var(--ring) 8%, transparent); + box-shadow: inset 3px 0 0 var(--ring); } 60% { border-color: var(--ring); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--ring) 30%, transparent), - 0 1px 2px 0 rgb(0 0 0 / 0.05); + background: color-mix(in srgb, var(--ring) 5%, transparent); + box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ring) 70%, transparent); } 100% { border-color: var(--border); - box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + background: transparent; + box-shadow: inset 0 0 0 transparent; } } diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 0437beb2b6a..9da2052a650 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -252,14 +252,14 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R Claude

- Add and switch Claude Code accounts without moving chat sessions to account-specific - config directories. + Optional. Orca can use your normal Claude login; add accounts only if you want quick + switching without moving chat sessions.

@@ -419,7 +419,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R Codex

- Add and switch between Codex accounts in Orca. + Optional. Orca can use your normal Codex login; add accounts only if you want quick + switching in Orca.

Each account keeps its own local sign-in context in Orca. Account auth stays on this diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 3fb6b902c3a..58d9b72ad01 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -278,33 +278,33 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea breaks that toast action even though this pane still renders fine. */}

- +

- Delete worktrees from the context menu without a confirmation dialog. Errors still - surface as a toast with a Force Delete fallback. + Show a confirmation before deleting a worktree from the context menu. Failed deletes + still surface a Force Delete fallback.

@@ -313,32 +313,32 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
- +

- Delete automations and their run history without a confirmation dialog. + Show a confirmation before deleting automations and their run history.

diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index e41640d71dd..5f66a1cab84 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -116,9 +116,26 @@ type SettingsNavSection = { description: string icon: typeof SlidersHorizontal searchEntries: SettingsSearchEntry[] + group: string badge?: string } +type SettingsNavGroup = { + id: string + title: string + sections: SettingsNavSection[] +} + +const SETTINGS_NAV_GROUPS = [ + { id: 'setup', title: 'Set Up' }, + { id: 'workflows', title: 'Workflows' }, + { id: 'interface', title: 'Interface' }, + { id: 'capabilities', title: 'AI Capabilities' }, + { id: 'remote', title: 'Remote Access' }, + { id: 'safety', title: 'Safety' }, + { id: 'experimental', title: 'Experimental' } +] as const + function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string { if (pane === 'repo' && repoId) { return `repo-${repoId}` @@ -416,61 +433,78 @@ function Settings(): React.JSX.Element { { id: 'general', title: 'General', - description: 'Workspace, editor, and updates.', + description: 'Workspace defaults, app setup, and maintenance.', icon: SlidersHorizontal, - searchEntries: GENERAL_PANE_SEARCH_ENTRIES + searchEntries: GENERAL_PANE_SEARCH_ENTRIES, + group: 'setup' }, { id: 'agents', title: 'Agents', description: 'Manage AI agents, set a default, and customize commands.', icon: Bot, - searchEntries: AGENTS_PANE_SEARCH_ENTRIES + searchEntries: AGENTS_PANE_SEARCH_ENTRIES, + group: 'setup' }, { id: 'accounts', - title: 'Agent Accounts', - description: 'Sign in and switch between Claude, Codex, Gemini, and OpenCode Go accounts.', + title: 'AI Provider Accounts', + description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.', icon: UserCog, - searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES + searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES, + group: 'setup', + badge: 'Optional' + }, + { + id: 'integrations', + title: 'Integrations', + description: 'Connect GitHub, GitLab, Linear, and source-hosting services.', + icon: Blocks, + searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES, + group: 'setup' }, { id: 'git', - title: 'Git', - description: 'Branch naming, local ref behavior, and AI commit messages.', + title: 'Git & Source Control', + description: 'Branch naming, base refs, attribution, and AI commit messages.', icon: GitBranch, // Why: the AI commit messages pane is rendered inside the Git section, // so its search entries belong to Git too — that way a query like // "claude" or "thinking" still surfaces the section. - searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES] + searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES], + group: 'workflows' }, { id: 'tasks', - title: 'Tasks', + title: 'Task Sources', description: 'Choose which task providers appear in the Tasks page and sidebar.', icon: ListChecks, - searchEntries: TASKS_PANE_SEARCH_ENTRIES + searchEntries: TASKS_PANE_SEARCH_ENTRIES, + group: 'workflows' }, { id: 'appearance', title: 'Appearance', - description: 'Theme and UI scaling.', + description: 'Theme, zoom, app font, sidebars, and status bar.', icon: Palette, - searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES + searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES, + group: 'interface' }, { id: 'input', title: 'Input & Editing', description: 'Selection and editing behavior.', icon: TextCursorInput, - searchEntries: INPUT_PANE_SEARCH_ENTRIES + searchEntries: INPUT_PANE_SEARCH_ENTRIES, + group: 'interface' }, { id: 'terminal', title: 'Terminal', - description: 'Terminal appearance, previews, and defaults for new panes.', + description: 'Shells, terminal appearance, quick commands, and pane behavior.', icon: SquareTerminal, - searchEntries: terminalPaneSearchEntries + searchEntries: terminalPaneSearchEntries, + group: 'workflows' }, ...(showDesktopOnlySettings ? [ @@ -479,14 +513,16 @@ function Settings(): React.JSX.Element { title: 'Browser', description: 'Home page, link routing, and session cookies.', icon: Globe, - searchEntries: BROWSER_PANE_SEARCH_ENTRIES + searchEntries: BROWSER_PANE_SEARCH_ENTRIES, + group: 'workflows' }, { id: 'notifications' as const, title: 'Notifications', description: 'Native desktop notifications for agent and terminal events.', icon: Bell, - searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES + searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES, + group: 'interface' } ] : []), @@ -495,26 +531,37 @@ function Settings(): React.JSX.Element { title: 'Orchestration', description: 'Coordinate multiple coding agents through Orca.', icon: Network, - searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES + searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES, + group: 'capabilities' }, { id: 'servers', - title: 'Servers', + title: 'Remote Orca Servers', description: isWebClient ? 'Connect this browser to a saved Orca server.' - : 'Run this client locally or through a remote Orca server.', + : 'Switch between local desktop mode and paired remote Orca runtimes.', icon: Server, searchEntries: [runtimeEnvironmentsSearchEntry], + group: 'remote', badge: 'Beta' }, ...(showDesktopOnlySettings ? [ + { + id: 'ssh' as const, + title: 'SSH Hosts', + description: 'Remote SSH hosts for files, terminals, and git.', + icon: Cable, + searchEntries: SSH_PANE_SEARCH_ENTRIES, + group: 'remote' + }, { id: 'mobile' as const, title: 'Mobile', description: 'Control terminals and agents from your phone.', icon: Smartphone, searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES, + group: 'remote', badge: 'Beta' }, { @@ -523,6 +570,7 @@ function Settings(): React.JSX.Element { description: 'Enable agents to control any app on your computer.', icon: MousePointerClick, searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES, + group: 'capabilities', badge: 'Beta' }, { @@ -531,6 +579,7 @@ function Settings(): React.JSX.Element { description: 'Local speech-to-text dictation with on-device models.', icon: Mic, searchEntries: VOICE_PANE_SEARCH_ENTRIES, + group: 'capabilities', badge: 'Beta' } ] @@ -539,10 +588,11 @@ function Settings(): React.JSX.Element { ? [ { id: 'developer-permissions' as const, - title: 'Permissions', + title: 'macOS Permissions', description: 'macOS privacy access for terminal-launched developer tools.', icon: ShieldCheck, - searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES + searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES, + group: 'safety' } ] : []), @@ -551,53 +601,40 @@ function Settings(): React.JSX.Element { title: 'Privacy & Telemetry', description: 'Anonymous usage data and telemetry controls.', icon: Lock, - searchEntries: PRIVACY_PANE_SEARCH_ENTRIES + searchEntries: PRIVACY_PANE_SEARCH_ENTRIES, + group: 'safety' }, { id: 'shortcuts', title: 'Shortcuts', description: 'Keyboard shortcuts for common actions.', icon: Keyboard, - searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES - }, - { - id: 'integrations', - title: 'Integrations', - description: 'GitHub, Linear, and other service connections.', - icon: Blocks, - searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES + searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES, + group: 'interface' }, { id: 'stats', title: 'Stats & Usage', description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.', icon: BarChart3, - searchEntries: STATS_PANE_SEARCH_ENTRIES + searchEntries: STATS_PANE_SEARCH_ENTRIES, + group: 'interface' }, - ...(showDesktopOnlySettings - ? [ - { - id: 'ssh' as const, - title: 'SSH', - description: 'Remote SSH connections.', - icon: Cable, - searchEntries: SSH_PANE_SEARCH_ENTRIES - } - ] - : []), { id: 'experimental', title: 'Experimental', description: 'New features that are still taking shape. Give them a try.', icon: FlaskConical, - searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES + searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES, + group: 'experimental' }, ...repos.map((repo) => ({ id: `repo-${repo.id}`, title: repo.displayName, description: `${getRepoKindLabel(repo)} • ${repo.path}`, icon: SlidersHorizontal, - searchEntries: getRepositoryPaneSearchEntries(repo) + searchEntries: getRepositoryPaneSearchEntries(repo), + group: 'repositories' })) ], [ @@ -958,6 +995,10 @@ function Settings(): React.JSX.Element { } const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-')) + const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({ + ...group, + sections: generalNavSections.filter((section) => section.group === group.id) + })).filter((group) => group.sections.length > 0) const repoNavSections = visibleNavSections .filter((section) => section.id.startsWith('repo-')) .map((section) => { @@ -970,7 +1011,7 @@ function Settings(): React.JSX.Element {
0} searchQuery={settingsSearchInputQuery} @@ -992,7 +1033,7 @@ function Settings(): React.JSX.Element { {isSectionMounted('general') ? ( @@ -1000,15 +1041,6 @@ function Settings(): React.JSX.Element { ) : null} - - {isSectionMounted('integrations') ? : null} - - {isSectionMounted('accounts') ? ( @@ -1031,10 +1064,19 @@ function Settings(): React.JSX.Element { ) : null} + + {isSectionMounted('integrations') ? : null} + + @@ -1069,35 +1111,10 @@ function Settings(): React.JSX.Element { ) : null} - - {isSectionMounted('appearance') ? ( - - ) : null} - - - - - - {showDesktopOnlySettings ? ( - <> - - {isSectionMounted('browser') ? ( - - ) : null} - - - - {isSectionMounted('notifications') ? ( - - ) : null} - - + + {isSectionMounted('browser') ? ( + + ) : null} + ) : null} + + {isSectionMounted('appearance') ? ( + + ) : null} + + + + + + + {showDesktopOnlySettings ? ( + + {isSectionMounted('notifications') ? ( + + ) : null} + + ) : null} + + + {isSectionMounted('shortcuts') ? : null} + + + + {isSectionMounted('stats') ? : null} + + : null} - - {isSectionMounted('servers') ? ( - - ) : null} - - {showDesktopOnlySettings ? ( <> - - {isSectionMounted('mobile') ? ( - - ) : null} - - ) : null} + + {isSectionMounted('servers') ? ( + + ) : null} + + + {showDesktopOnlySettings ? ( + <> + + {isSectionMounted('ssh') ? : null} + + + + {isSectionMounted('mobile') ? ( + + ) : null} + + + ) : null} + {showDesktopOnlySettings && isMac ? ( @@ -1271,35 +1344,6 @@ function Settings(): React.JSX.Element { {isSectionMounted('privacy') ? : null} - - {isSectionMounted('shortcuts') ? : null} - - - - {isSectionMounted('stats') ? : null} - - - {showDesktopOnlySettings ? ( - - {isSectionMounted('ssh') ? : null} - - ) : null} -
-

+

{title} {badge ? ( diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index ba10f88c50b..76ca7f95981 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -13,6 +13,12 @@ type NavSection = { badge?: string } +type NavGroup = { + id: string + title: string + sections: NavSection[] +} + type RepoNavSection = NavSection & { badgeColor?: string isRemote?: boolean @@ -20,7 +26,7 @@ type RepoNavSection = NavSection & { type SettingsSidebarProps = { activeSectionId: string - generalSections: NavSection[] + generalGroups: NavGroup[] repoSections: RepoNavSection[] hasRepos: boolean searchQuery: string @@ -35,7 +41,7 @@ type SettingsSidebarProps = { export function SettingsSidebar({ activeSectionId, - generalSections, + generalGroups, repoSections, hasRepos, searchQuery, @@ -78,39 +84,46 @@ export function SettingsSidebar({
-
- {generalSections.map((section) => { - const Icon = section.icon - const isActive = activeSectionId === section.id + {generalGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.sections.map((section) => { + const Icon = section.icon + const isActive = activeSectionId === section.id - return ( - - ) - })} -
+ return ( + + ) + })} +
+
+ ))}

diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index df61ce8b9bc..6166af302bc 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -3,21 +3,21 @@ import type { SettingsSearchEntry } from './settings-search' export const ACCOUNTS_CLAUDE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Claude Accounts', - description: 'Manage which Claude account Orca uses while preserving shared chat context.', - keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota'] + description: 'Optional account switching for Claude while preserving shared chat context.', + keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota', 'optional'] } ] export const ACCOUNTS_CODEX_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Codex Accounts', - description: 'Manage which Codex account Orca uses for live rate limit fetching.', - keywords: ['codex', 'account', 'rate limit', 'status bar', 'quota'] + description: 'Optional account switching for Codex and live rate limit fetching.', + keywords: ['codex', 'account', 'rate limit', 'status bar', 'quota', 'optional'] }, { title: 'Active Codex Account', - description: 'Choose which saved Codex account powers live quota reads.', - keywords: ['codex', 'account', 'switch', 'active', 'status bar'] + description: 'Choose which optional saved Codex account powers live quota reads.', + keywords: ['codex', 'account', 'switch', 'active', 'status bar', 'optional'] } ] diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 8989ae42cf2..f847e787317 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -12,13 +12,13 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ keywords: ['nested', 'subfolder', 'directory'] }, { - title: 'Skip Delete Worktree Confirmation', - description: 'Delete worktrees from the context menu without a confirmation dialog.', + title: 'Ask Before Deleting Worktrees', + description: 'Show a confirmation dialog before deleting a worktree.', keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt'] }, { - title: 'Skip Delete Automation Confirmation', - description: 'Delete automations without a confirmation dialog.', + title: 'Ask Before Deleting Automations', + description: 'Show a confirmation dialog before deleting an automation and its run history.', keywords: ['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt'] }, { diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 1d7c365f011..c422fe9cb7f 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -300,7 +300,7 @@ export function SshStatusSegment({ { - openSettingsTarget({ pane: 'general', repoId: null, sectionId: 'ssh' }) + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) setActiveView('settings') }} > From e2fa5e354c3fda30653830f92af7e71ba2740a6e Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 22:43:58 -0700 Subject: [PATCH 07/20] fix: pr-bug-scan validated finding from #2270 (#2298) * fix: address pr-bug-scan validated finding from #2270 Restored legacy local hook fallback at hooks.ts:286-307 when commandSourcePolicy is undefined and persisted scripts.setup/archive are non-empty; also patched getSetupCommandSource and updated the regr * Resolve direct PR launch start point correctly - Prevent direct "Use PR" launches from skipping PR head resolution - Avoid legacy local hook fallback whenever an orca.yaml file exists --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/hooks.test.ts | 81 ++++++++++++++++++- src/main/hooks.ts | 47 +++++++++-- .../src/lib/launch-work-item-direct.ts | 41 +++++++++- 3 files changed, 158 insertions(+), 11 deletions(-) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index f8744474ff3..a6ce1718c9d 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -346,7 +346,7 @@ describe('getEffectiveHooks', () => { expect(result?.scripts.setup).not.toContain('old-version') }) - it('does not fall back to local settings hooks by default when yaml is missing', async () => { + it('falls back to legacy local hooks when policy is unset and yaml is missing', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(false) @@ -357,6 +357,26 @@ describe('getEffectiveHooks', () => { }) const result = getEffectiveHooks(repo) + expect(result).toEqual({ + scripts: { + setup: 'echo "local setup"', + archive: 'echo "local archive"' + } + }) + }) + + it('does not fall back to local hooks when policy is explicitly shared-only', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(false) + + const { getEffectiveHooks } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + commandSourcePolicy: 'shared-only', + scripts: { setup: 'echo "local setup"', archive: 'echo "local archive"' } + }) + const result = getEffectiveHooks(repo) + expect(result).toBeNull() }) @@ -438,6 +458,21 @@ describe('getEffectiveHooks', () => { }) }) + it('does not fall back to legacy local hooks when yaml exists without supported hooks', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n') + + const { getEffectiveHooks } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' } + }) + const result = getEffectiveHooks(repo) + + expect(result).toBeNull() + }) + it('treats legacy shared-first policy as orca.yaml only', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -468,6 +503,50 @@ describe('getEffectiveHooks', () => { expect(result).toBeNull() }) + + it('falls back to legacy local setup source only when yaml is missing', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(false) + + const { getSetupCommandSource } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: 'echo "legacy setup"', archive: '' } + }) + const result = getSetupCommandSource(repo) + + expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' }) + }) + + it('does not use legacy local setup source when yaml omits setup', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n') + + const { getSetupCommandSource } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: 'echo "legacy setup"', archive: '' } + }) + const result = getSetupCommandSource(repo) + + expect(result).toBeNull() + }) + + it('does not use legacy local setup source when yaml exists without supported hooks', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n') + + const { getSetupCommandSource } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: 'echo "legacy setup"', archive: '' } + }) + const result = getSetupCommandSource(repo) + + expect(result).toBeNull() + }) }) describe('runHook', () => { diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 32c9eb540ab..08bf04695dd 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -267,7 +267,8 @@ function ensureOrcaDirIgnored(repoPath: string): void { function getEffectiveHookScript( yamlScript: string | undefined, localScript: string | undefined, - policy: HookCommandSourcePolicy + policy: HookCommandSourcePolicy, + legacyFallback: boolean ): string | undefined { const shared = yamlScript?.trim() const local = localScript?.trim() @@ -280,16 +281,38 @@ function getEffectiveHookScript( return [shared, local].filter(Boolean).join('\n') || undefined } + // Why: existing users persisted local setup/archive scripts before + // commandSourcePolicy existed. Without a fallback the new default + // 'shared-only' would silently drop those scripts when orca.yaml is missing. + if (legacyFallback) { + return shared || local || undefined + } + return shared || undefined } +function hasLegacyLocalScripts(repo: Repo): boolean { + return Boolean( + repo.hookSettings?.scripts.setup?.trim() || repo.hookSettings?.scripts.archive?.trim() + ) +} + export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null { - const yamlHooks = loadHooks(worktreePath ?? repo.path) + const hooksRoot = worktreePath ?? repo.path + const yamlHooks = loadHooks(hooksRoot) + const yamlFileExists = hasHooksFile(hooksRoot) const localSetup = repo.hookSettings?.scripts.setup const localArchive = repo.hookSettings?.scripts.archive - const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy) - const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy) - const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, policy) + const rawPolicy = repo.hookSettings?.commandSourcePolicy + const policy = normalizeHookCommandSourcePolicy(rawPolicy) + const legacyFallback = rawPolicy === undefined && !yamlFileExists && hasLegacyLocalScripts(repo) + const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy, legacyFallback) + const archive = getEffectiveHookScript( + yamlHooks?.scripts.archive, + localArchive, + policy, + legacyFallback + ) if (!setup && !archive) { return null @@ -330,9 +353,13 @@ export function getSetupCommandSource( repo: Repo, worktreePath?: string ): { source: 'yaml' | 'local' | 'both'; command: string } | null { - const yamlSetup = loadHooks(worktreePath ?? repo.path)?.scripts.setup?.trim() + const hooksRoot = worktreePath ?? repo.path + const yamlHooks = loadHooks(hooksRoot) + const yamlFileExists = hasHooksFile(hooksRoot) + const yamlSetup = yamlHooks?.scripts.setup?.trim() const localSetup = repo.hookSettings?.scripts.setup?.trim() - const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy) + const rawPolicy = repo.hookSettings?.commandSourcePolicy + const policy = normalizeHookCommandSourcePolicy(rawPolicy) if (policy === 'local-only') { return localSetup ? { source: 'local', command: localSetup } : null @@ -346,6 +373,12 @@ export function getSetupCommandSource( return { source: 'yaml', command: yamlSetup } } + // Why: pre-policy persisted local setup scripts must keep working when + // orca.yaml is missing; otherwise upgrading silently drops the legacy hook. + if (rawPolicy === undefined && !yamlFileExists && localSetup && hasLegacyLocalScripts(repo)) { + return { source: 'local', command: localSetup } + } + return null } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 1914f8d7879..cbff5b379c0 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,10 +1,11 @@ import { toast } from 'sonner' -import { useAppStore } from '@/store' +import { useAppStore, type AppState } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { CLIENT_PLATFORM, getLinkedWorkItemSuggestedName, @@ -15,6 +16,7 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import type { + GitPushTarget, OrcaHooks, RepoHookSettings, SetupDecision, @@ -86,6 +88,24 @@ function pickAgent( return null } +async function resolveDirectPrStartPoint( + repoId: string, + prNumber: number, + settings: AppState['settings'] +): Promise<{ baseBranch: string; pushTarget?: GitPushTarget }> { + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'local' + ? await window.api.worktrees.resolvePrBase({ repoId, prNumber }) + : await callRuntimeRpc< + { baseBranch: string; pushTarget?: GitPushTarget } | { error: string } + >(target, 'worktree.resolvePrBase', { repo: repoId, prNumber }, { timeoutMs: 30_000 }) + if ('error' in result) { + throw new Error(result.error) + } + return result +} + async function resolveSetupDecision( repoId: string, repo: { hookSettings?: RepoHookSettings } @@ -207,6 +227,21 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom linkedIssueNumber: item.type === 'issue' ? (item.number ?? null) : null, linkedPR: item.type === 'pr' ? (item.number ?? null) : null }) + let resolvedBaseBranch = baseBranch + let resolvedPushTarget: GitPushTarget | undefined + if (!resolvedBaseBranch && item.type === 'pr' && item.number) { + try { + // Why: direct "Use PR" launches bypass the Start-from picker, so they + // must still resolve the PR head before `git worktree add`. + const result = await resolveDirectPrStartPoint(repoId, item.number, settings) + resolvedBaseBranch = result.baseBranch + resolvedPushTarget = result.pushTarget + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to resolve PR head.') + openModalFallback() + return + } + } let worktreeId: string let primaryTabId: string | null @@ -217,14 +252,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const result = await store.createWorktree( repoId, workspaceName, - baseBranch, + resolvedBaseBranch, finalSetupDecision, undefined, telemetrySource, item.title, item.type === 'issue' && item.number ? item.number : undefined, item.type === 'pr' && item.number ? item.number : undefined, - undefined, + resolvedPushTarget, undefined, item.linearIdentifier ) From 94f47c3e3260db075b2144f8b804fa55329dfdd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 05:47:32 +0000 Subject: [PATCH 08/20] release: v1.4.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5e4cca9beb..bc43389a35e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.8-rc.0", + "version": "1.4.8", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From a6ca940d0f0688c8ea0d6550cfb869ee9b3cadd4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 18 May 2026 23:06:54 -0700 Subject: [PATCH 09/20] Show GitHub PR reviewer names in task rows (#2309) --- src/main/github/client-work-items.test.ts | 66 +- src/main/github/client.test.ts | 40 +- src/main/github/client.ts | 16 +- .../src/components/GitHubItemDialog.tsx | 285 +++++++- src/renderer/src/components/TaskPage.tsx | 678 ++++++++++-------- .../github-pr-reviewer-display.test.ts | 127 ++++ .../components/github-pr-reviewer-display.ts | 164 +++++ .../components/github-project/ProjectRow.tsx | 26 +- .../github-project/ProjectViewList.tsx | 55 +- .../github-project/ProjectViewWrapper.tsx | 20 +- .../sidebar/CommentMarkdown.test.tsx | 25 + .../components/sidebar/CommentMarkdown.tsx | 15 +- .../task-page-cache-selectors.test.ts | 21 + .../components/task-page-cache-selectors.ts | 33 + src/renderer/src/store/slices/github.test.ts | 45 +- src/renderer/src/store/slices/github.ts | 10 +- 16 files changed, 1257 insertions(+), 369 deletions(-) create mode 100644 src/renderer/src/components/github-pr-reviewer-display.test.ts create mode 100644 src/renderer/src/components/github-pr-reviewer-display.ts create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.test.tsx diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index a13d62506fc..b4ba830b622 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -114,7 +114,16 @@ describe('listWorkItems', () => { author: { login: 'octocat' }, isDraft: false, headRefName: 'feature/add-feature', - baseRefName: 'main' + baseRefName: 'main', + reviewRequests: [ + { + requestedReviewer: { + login: 'AmethystLiang', + name: 'Amethyst Liang', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + } + ] } ]) }) @@ -147,7 +156,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--assignee', @@ -157,7 +166,7 @@ describe('listWorkItems', () => { ) const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',') expect(prListFields).not.toContain('statusCheckRollup') - expect(prListFields).not.toContain('reviewRequests') + expect(prListFields).toContain('reviewRequests') expect(prListFields).not.toContain('mergeStateStatus') expect(items).toEqual([ { @@ -182,7 +191,14 @@ describe('listWorkItems', () => { updatedAt: '2026-03-28T00:00:00Z', author: 'octocat', branchName: 'feature/add-feature', - baseRefName: 'main' + baseRefName: 'main', + reviewRequests: [ + { + login: 'AmethystLiang', + name: 'Amethyst Liang', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] } ]) }) @@ -215,7 +231,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--state', @@ -315,7 +331,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--state', @@ -354,26 +370,24 @@ describe('listWorkItems', () => { it('marks fork PRs as cross-repository when REST payload only includes head.label', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: '[]' }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 1849, - title: 'Fork PR with missing head repo', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/1849', - updated_at: '2026-04-01T00:00:00Z', - user: { login: 'contributor' }, - head: { - ref: 'feat/onboarding-model-choice-782', - repo: null, - label: 'contributor:feat/onboarding-model-choice-782' - }, - base: { ref: 'main' } - } - ]) - }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 1849, + title: 'Fork PR with missing head repo', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/1849', + updated_at: '2026-04-01T00:00:00Z', + user: { login: 'contributor' }, + head: { + ref: 'feat/onboarding-model-choice-782', + repo: null, + label: 'contributor:feat/onboarding-model-choice-782' + }, + base: { ref: 'main' } + } + ]) + }) const { items } = await listWorkItems('/repo-root', 10) expect(items).toEqual([ diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 864ba558cba..f278624ae28 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -84,6 +84,7 @@ vi.mock('./rate-limit', () => ({ import { getPRComments, getPRForBranch, + getWorkItem, getPullRequestPushTarget, mergePR, resolveReviewThread, @@ -824,17 +825,46 @@ describe('getPRForBranch', () => { remoteName: 'origin', branchName: 'feature/test' }) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - ['api', 'repos/fork/orca/pulls/1849'], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/fork/orca/pulls/1849'], { + cwd: '/repo-root' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/stablyai/orca/pulls/1849'], { cwd: '/repo-root' } ) }) + + it('normalizes reviewer avatars from REST pull request payloads', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Review me', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/42', + labels: [], + updated_at: '2026-03-28T00:00:00Z', + user: { login: 'author' }, + draft: false, + requested_reviewers: [ + { + login: 'AmethystLiang', + avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] + }) + }) + + await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ + reviewRequests: [ + { + login: 'AmethystLiang', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] + }) + }) }) describe('GitHub GraphQL rate-limit guard', () => { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 82ed230bc9a..588be53125d 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -249,11 +249,12 @@ export async function getAuthenticatedViewer(): Promise { type MainWorkItem = Omit const WORK_ITEM_PR_LIST_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests' // Why: these fields are intentionally excluded from `gh pr list` because -// statusCheckRollup/review/merge metadata fan out into expensive GraphQL work -// across every row. Fetch them only for single-PR detail surfaces. +// statusCheckRollup/review decision/merge metadata fan out into expensive +// GraphQL work across every row. Requested reviewers are kept in the list +// payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' @@ -342,7 +343,12 @@ function userFromUnknown( return { login, name: typeof raw.name === 'string' ? raw.name : null, - avatarUrl: typeof raw.avatarUrl === 'string' ? raw.avatarUrl : '' + avatarUrl: + typeof raw.avatarUrl === 'string' + ? raw.avatarUrl + : typeof raw.avatar_url === 'string' + ? raw.avatar_url + : '' } } @@ -2504,7 +2510,7 @@ export async function requestPRReviewers( ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { - return { ok: false, error: 'Enter at least one reviewer login' } + return { ok: false, error: 'Enter at least one reviewer' } } const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) const ownerRepo = await getOwnerRepo(repoPath, connectionId) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 92f035a7cc8..a0635b7fa6c 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -34,11 +34,13 @@ import { RefreshCw, Send, UndoDot, + Users, X } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' +import { Input } from '@/components/ui/input' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import { VisuallyHidden } from 'radix-ui' import { @@ -84,6 +86,11 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' +import { + appendGitHubPRRequestedReviewers, + getGitHubPRReviewerRows, + normalizeGitHubReviewerLogins +} from '@/components/github-pr-reviewer-display' import type { GitHubOwnerRepo, GitHubPRFile, @@ -177,6 +184,10 @@ type GitHubItemDialogProps = { repoId?: string | null /** Called when the user clicks the primary CTA to start work from this item. */ onUse: (item: GitHubWorkItem) => void + onReviewRequestsChange?: ( + itemKey: { id: string; repoId: string }, + reviewRequests: GitHubAssignableUser[] + ) => void onClose: () => void /** Optional Project-origin context. When set, edits in the dialog are * routed via slug-addressed mutation IPCs against the row's actual repo @@ -359,6 +370,193 @@ function WorkItemStateBadge({ ) } +function ReviewerAvatar({ + login, + avatarUrl +}: { + login: string + avatarUrl: string +}): React.JSX.Element { + if (avatarUrl) { + return ( + + ) + } + return ( + + {login.slice(0, 1).toUpperCase()} + + ) +} + +function PRReviewersPanel({ + item, + loading, + repoPath, + onReviewersRequested +}: { + item: GitHubWorkItem + loading: boolean + repoPath: string | null + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +}): React.JSX.Element { + const [reviewerInput, setReviewerInput] = useState('') + const [submitting, setSubmitting] = useState(false) + const [localReviewRequests, setLocalReviewRequests] = useState( + () => item.reviewRequests ?? [] + ) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + + useEffect(() => { + setLocalReviewRequests(item.reviewRequests ?? []) + }, [item.id, item.reviewRequests]) + + const displayItem = { ...item, reviewRequests: localReviewRequests } + const reviewers = getGitHubPRReviewerRows(displayItem) + const selectedReviewerLogins = useMemo( + () => + new Set( + localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) + ), + [localReviewRequests] + ) + const hasReviewerMetadata = + item.reviewDecision !== undefined || + localReviewRequests.length > 0 || + item.reviewRequests !== undefined || + item.latestReviews !== undefined + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(useAppStore.getState().settings).kind === 'environment' + + const handleRequestReview = async (event: React.FormEvent): Promise => { + event.preventDefault() + if (submitting) { + return + } + const logins = normalizeGitHubReviewerLogins( + reviewerInput.split(/[\s,]+/), + selectedReviewerLogins + ) + if (logins.length === 0) { + toast.error('Enter a reviewer') + return + } + if (localReviewRequests.length + logins.length > 15) { + toast.error('You can request up to 15 reviewers') + return + } + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + if (target.kind !== 'environment' && !repoPath) { + toast.error('No repo context available for this pull request.') + return + } + setSubmitting(true) + try { + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.requestPRReviewers', + { repo: item.repoId, prNumber: item.number, reviewers: logins }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.requestPRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + prNumber: item.number, + reviewers: logins + }) + if (!result.ok) { + toast.error(result.error ?? 'Failed to request reviewer') + return + } + const nextReviewRequests = appendGitHubPRRequestedReviewers(localReviewRequests, logins) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + onReviewersRequested(nextReviewRequests) + setReviewerInput('') + toast.success(logins.length === 1 ? 'Reviewer requested' : 'Reviewers requested') + } catch { + toast.error('Failed to request reviewer') + } finally { + setSubmitting(false) + } + } + + return ( +

+ ) +} + function fileStatusTone(status: GitHubPRFile['status']): string { switch (status) { case 'added': @@ -743,6 +941,25 @@ function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { }) } +function patchCachedPRReviewRequests( + cacheKey: string, + reviewRequests: GitHubAssignableUser[] +): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { + ...prev.details, + item: { ...prev.details.item, reviewRequests } + }, + fetchedAt: Date.now(), + error: undefined + }) +} + // 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 @@ -1484,7 +1701,8 @@ function ConversationTab({ onUse, onMutated, onChecksUpdated, - onCommentAdded + onCommentAdded, + onReviewersRequested }: { item: GitHubWorkItem repoPath: string | null @@ -1504,6 +1722,7 @@ function ConversationTab({ onMutated: () => void onChecksUpdated: (checks: PRCheckDetail[]) => void onCommentAdded: (comment: PRComment) => void + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const authorLabel = item.author ?? 'unknown' const [replyingTo, setReplyingTo] = useState(null) @@ -1573,7 +1792,7 @@ function ConversationTab({ const startWorkspaceButton = ( ) - const inserted = `@${suggestion.login}` - const nextValue = `${reviewerInput.slice(0, token.start)}${inserted}${reviewerInput.slice(token.end)}` - const nextCaret = token.start + inserted.length - setReviewerInput(nextValue) - setReviewerInputCaret(nextCaret) - setReviewerSuggestionsOpen(false) - requestAnimationFrame(() => { - reviewerInputRef.current?.focus() - reviewerInputRef.current?.setSelectionRange(nextCaret, nextCaret) - }) } return ( - + event.stopPropagation()} > -
-
-
Reviewers
-
- {reviewers.length > 0 ? ( - reviewers.slice(0, 6).map((reviewer) => ( -
- @{reviewer.login} - - {reviewer.state} - +
+
+ Request up to 15 reviewers +
+
+
+ setReviewerInput(event.target.value)} + placeholder="Type or choose a user" + disabled={!repo || submitting} + className="h-8 rounded-md bg-background px-2 text-[13px]" + aria-label="Type or choose a user" + aria-autocomplete="list" + onKeyDown={(event) => { + if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length) + return + } + if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex( + (current) => + (current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length + ) + return + } + if (event.key === 'Enter') { + event.preventDefault() + const activeReviewer = actionableReviewerRows[activeReviewerIndex] + if (activeReviewer) { + void requestReviewer(activeReviewer) + return + } + void handleRequestReview() + return + } + if (event.key === 'Escape') { + event.preventDefault() + handleReviewerPickerOpenChange(false) + } + }} + /> +
+
+ {reviewerMetadata.loading ? ( +
Loading…
+ ) : filteredReviewerCandidates.length > 0 ? ( + <> + {suggestedReviewerRows.length > 0 ? ( + <> +
+ Suggestions
- )) + {suggestedReviewerRows.map((reviewer, index) => + renderReviewerPickerRow(reviewer, { suggested: true, activeIndex: index }) + )} + + ) : null} +
+ Everyone else +
+ {everyoneElseReviewerRows.length > 0 ? ( + everyoneElseReviewerRows.map((reviewer, index) => + renderReviewerPickerRow(reviewer, { + suggested: false, + activeIndex: suggestedReviewerRows.length + index + }) + ) ) : ( -
- {hasReviewerMetadata - ? 'No reviewers requested yet.' - : 'Open the PR details to view current reviewers.'} +
+ No matching reviewers.
)} + + ) : ( +
+ {reviewerMetadata.error ?? + (hasReviewerMetadata + ? 'No matching reviewers.' + : 'Open the PR details to view current reviewers.')}
-
-
-
- { - setReviewerInput(event.target.value) - setReviewerInputCaret( - event.currentTarget.selectionStart ?? event.target.value.length - ) - setReviewerSuggestionsOpen(true) - }} - onClick={(event) => { - setReviewerInputCaret(event.currentTarget.selectionStart ?? reviewerInput.length) - setReviewerSuggestionsOpen(true) - }} - onFocus={(event) => { - setReviewerInputCaret(event.currentTarget.selectionStart ?? reviewerInput.length) - setReviewerSuggestionsOpen(true) - }} - onBlur={() => setReviewerSuggestionsOpen(false)} - onKeyUp={(event) => { - if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) { - setReviewerInputCaret( - event.currentTarget.selectionStart ?? reviewerInput.length - ) - } - }} - placeholder="login or @login" - disabled={!repo || submitting} - className="h-8 text-xs" - aria-expanded={showReviewerSuggestions} - aria-autocomplete="list" - onKeyDown={(event) => { - if (showReviewerSuggestions && reviewerSuggestions.length > 0) { - if (event.key === 'ArrowDown') { - event.preventDefault() - setActiveReviewerSuggestionIndex( - (current) => (current + 1) % reviewerSuggestions.length - ) - return - } - if (event.key === 'ArrowUp') { - event.preventDefault() - setActiveReviewerSuggestionIndex( - (current) => - (current - 1 + reviewerSuggestions.length) % reviewerSuggestions.length - ) - return - } - if (event.key === 'Enter' || event.key === 'Tab') { - event.preventDefault() - insertReviewerSuggestion( - reviewerSuggestions[activeReviewerSuggestionIndex] ?? reviewerSuggestions[0] - ) - return - } - } - if (event.key === 'Escape' && reviewerSuggestionsOpen) { - event.preventDefault() - setReviewerSuggestionsOpen(false) - return - } - if (event.key === 'Enter') { - event.preventDefault() - void handleRequestReview() - } - }} - /> - -
- {showReviewerSuggestions && ( -
- {reviewerMetadata.loading ? ( -
Loading…
- ) : reviewerSuggestions.length > 0 ? ( - reviewerSuggestions.map((suggestion, index) => ( - - )) - ) : ( -
- {reviewerMetadata.error ?? 'No matching reviewers.'} -
- )} -
- )} -
+ )}
@@ -1825,6 +1846,7 @@ export default function TaskPage(): React.JSX.Element { defaultTaskViewPreset ) const [tasksLoading, setTasksLoading] = useState(false) + const [tasksRefreshing, setTasksRefreshing] = useState(false) const [tasksError, setTasksError] = useState(null) // Why: per-repo failure count surfaced through the "N of M" banner. IPC-level // rejections populate tasksError instead — the two are mutually exclusive so @@ -1908,6 +1930,34 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItemFallback(item) }, []) + const patchTaskPageWorkItemRows = useCallback( + (itemKey: { id: string; repoId: string }, patch: Partial): void => { + setPages((current) => { + let changed = false + const nextPages = current.map((page) => { + let pageChanged = false + const nextPage = page.map((item) => { + if (item.id !== itemKey.id || item.repoId !== itemKey.repoId) { + return item + } + pageChanged = true + changed = true + return { ...item, ...patch } + }) + return pageChanged ? nextPage : page + }) + return changed ? nextPages : current + }) + }, + [] + ) + const handleDialogReviewRequestsChange = useCallback( + (itemKey: { id: string; repoId: string }, reviewRequests: GitHubAssignableUser[]): void => { + patchTaskPageWorkItemRows(itemKey, { reviewRequests }) + }, + [patchTaskPageWorkItemRows] + ) + // Why: feature 1 — render the "Issues from {owner}/{repo}" indicator per // selected repo whose issue-source and PR-source slugs differ, and surface // a per-repo retryable banner when the issue-side fetch failed. Both derive @@ -1922,6 +1972,17 @@ export default function TaskPage(): React.JSX.Element { [selectedRepos, selectedWorkItemsCacheEntries] ) + useEffect(() => { + if (taskSource !== 'github' || githubMode !== 'items') { + return + } + // Why: inline/dialog edits patch `workItemsCache`; the paged table renders + // from a local snapshot so it needs the patched row objects copied across. + setPages((current) => + reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries) + ) + }, [githubMode, selectedWorkItemsCacheEntries, taskSource]) + // Why: surface a one-time toast per session per repo when the user's // preferred `'upstream'` is no longer configured and we fell back to // origin. Gated on a ref-backed set so repeated list refreshes don't @@ -1981,6 +2042,10 @@ export default function TaskPage(): React.JSX.Element { }, [selectedRepos] ) + const handleRefreshGithubTasks = useCallback((): void => { + setTasksRefreshing(true) + setTaskRefreshNonce((current) => current + 1) + }, []) const [newIssueOpen, setNewIssueOpen] = useState(false) const [newIssueTitle, setNewIssueTitle] = useState('') const [newIssueBody, setNewIssueBody] = useState('') @@ -2692,10 +2757,12 @@ export default function TaskPage(): React.JSX.Element { // button would stay stuck in its disabled/Retrying state indefinitely. if (taskSource !== 'github' || githubMode !== 'items') { setRetryingRepoPaths(new Set()) + setTasksRefreshing(false) return } if (selectedRepos.length === 0) { setRetryingRepoPaths(new Set()) + setTasksRefreshing(false) return } // unreachable — multi-combobox forbids empty @@ -2745,6 +2812,11 @@ export default function TaskPage(): React.JSX.Element { const preferenceInvalidated = workItemsInvalidationNonce !== lastFetchedInvalidationNonceRef.current lastFetchedInvalidationNonceRef.current = workItemsInvalidationNonce + const forcedFetch = (forceRefresh && taskRefreshNonce > 0) || preferenceInvalidated + // Why: manual refresh keeps cached rows visible, so the normal + // `tasksLoading` flag may stay false. Track the forced fetch separately + // so the toolbar still shows a refresh-in-progress affordance. + setTasksRefreshing(forcedFetch) const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path })) // Why: snapshot the retrying paths at effect-dispatch so overlapping @@ -2754,7 +2826,7 @@ export default function TaskPage(): React.JSX.Element { // when this effect dispatched preserves later additions. const dispatchedRetryPaths = retryingRepoPaths void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { - force: (forceRefresh && taskRefreshNonce > 0) || preferenceInvalidated + force: forcedFetch }) .then(({ items, failedCount: failed }) => { // Why: clear only the repos this effect was responsible for @@ -2780,6 +2852,7 @@ export default function TaskPage(): React.JSX.Element { setCurrentPage(0) setFailedCount(failed) setTasksLoading(false) + setTasksRefreshing(false) }) .catch((err) => { // Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a @@ -2806,6 +2879,7 @@ export default function TaskPage(): React.JSX.Element { setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.') setFailedCount(0) // the per-repo banner would be misleading next to tasksError setTasksLoading(false) + setTasksRefreshing(false) }) // Why: fire-and-forget count query in parallel with the items fetch. @@ -3123,6 +3197,8 @@ export default function TaskPage(): React.JSX.Element { setSelectedLinearIssue ]) + const githubTasksBusy = tasksLoading || tasksRefreshing + useEffect(() => { // Why: when a modal is open, let it own Esc dismissal. if ( @@ -3382,19 +3458,19 @@ export default function TaskPage(): React.JSX.Element { return (
-
+
{/* Why: pt-1.5 vertically centers this row's 32px icon cluster (X + source toggles) with the sidebar's "Tasks" nav row. Sidebar Tasks center sits 22px below the titlebar (pt-2 + py-1.5 + half size-4 icon). Matching that here needs 6px top padding above the 32px cluster (6 + 16 = 22). The previous pt-3 placed the cluster 6px too low, breaking the visual band across the top chrome. */} -
+
-
+
{/* Why: Close is anchored left in the same row as the source icons so the top chrome is one compact band. Left-aligned keeps it clear of the app sidebar on the @@ -3478,7 +3554,7 @@ export default function TaskPage(): React.JSX.Element { ) : null} -
+
{taskSource === 'github' ? ( -
+
{projectModeVisible ? (
{GITHUB_MODE_BUTTONS.map((mode) => { @@ -3545,7 +3621,7 @@ export default function TaskPage(): React.JSX.Element { inert — hide it to avoid suggesting it does something. */} {githubMode !== 'project' && ( -
+
-
-
+
+
+
{getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { const active = activeTaskPreset === option.id @@ -3637,12 +3713,15 @@ export default function TaskPage(): React.JSX.Element { - Refresh GitHub work + {githubTasksBusy ? 'Refreshing GitHub work…' : 'Refresh GitHub work'}
-
+
) : taskSource === 'linear' && linearStatus.connected ? ( -
+
{LINEAR_PRESETS.map((preset) => { @@ -3832,8 +3911,8 @@ export default function TaskPage(): React.JSX.Element {
-
-
+
+
) : taskSource === 'gitlab' ? ( -
+
{/* Why: view toggle — Project = the selected repo's MRs and issues; My Todos = the user's cross-project gitlab.com/dashboard/todos stream. They have @@ -3972,11 +4051,11 @@ export default function TaskPage(): React.JSX.Element {
{taskSource === 'github' && githubMode === 'project' ? ( -
+
) : taskSource === 'github' ? ( -
+
- ID - Title / Context + ID + Title / Context Branch Status {showPRManagementColumns ? ( @@ -4071,10 +4150,10 @@ export default function TaskPage(): React.JSX.Element {
{Array.from({ length: 3 }).map((_, i) => (
-
+
-
+
@@ -4154,11 +4233,11 @@ export default function TaskPage(): React.JSX.Element { } }} className={cn( - 'grid cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50', + 'group/github-task-row grid cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50', githubTaskGridClass )} > -
+
{item.type === 'pr' ? ( @@ -4171,7 +4250,7 @@ export default function TaskPage(): React.JSX.Element {
-
+

{item.title} @@ -5332,6 +5411,7 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem(null) handleUseWorkItem(item) }} + onReviewRequestsChange={handleDialogReviewRequestsChange} onClose={() => setDialogWorkItem(null)} /> diff --git a/src/renderer/src/components/github-pr-reviewer-display.test.ts b/src/renderer/src/components/github-pr-reviewer-display.test.ts new file mode 100644 index 00000000000..62a762ad70c --- /dev/null +++ b/src/renderer/src/components/github-pr-reviewer-display.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' + +import type { GitHubWorkItem } from '../../../shared/types' +import { + appendGitHubPRRequestedReviewers, + getGitHubPRPrimaryReviewer, + getGitHubPRReviewerRows, + getGitHubPRReviewLabel, + normalizeGitHubReviewerLogins +} from './github-pr-reviewer-display' + +function item(patch: Partial): GitHubWorkItem { + return patch as GitHubWorkItem +} + +describe('GitHub PR reviewer display', () => { + it('shows the requested reviewer instead of a request count', () => { + expect( + getGitHubPRReviewLabel( + item({ + reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }] + }) + ) + ).toBe('ExampleReviewer') + }) + + it('keeps multiple reviewers compact while still naming the first reviewer', () => { + expect( + getGitHubPRReviewLabel( + item({ + reviewRequests: [ + { login: 'ExampleReviewer', name: null, avatarUrl: '' }, + { login: 'agent-slack', name: null, avatarUrl: '' }, + { login: 'stably', name: null, avatarUrl: '' } + ] + }) + ) + ).toBe('ExampleReviewer +2') + }) + + it('preserves stronger review decision labels', () => { + expect( + getGitHubPRReviewLabel( + item({ + reviewDecision: 'APPROVED', + reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: '' }] + }) + ) + ).toBe('Approved') + }) + + it('falls back to reviewed users and empty metadata labels', () => { + expect(getGitHubPRReviewLabel(item({ latestReviews: [{ login: 'reviewer' }] }))).toBe( + 'reviewer' + ) + expect(getGitHubPRReviewLabel(item({ reviewRequests: [] }))).toBe('No reviewers') + expect(getGitHubPRReviewLabel(item({}))).toBe('Reviewers') + }) + + it('returns the primary reviewer avatar without requiring another lookup', () => { + expect( + getGitHubPRPrimaryReviewer( + item({ + reviewRequests: [ + { + login: 'ExampleReviewer', + name: null, + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] + }) + ) + ).toEqual({ + login: 'ExampleReviewer', + name: null, + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + }) + }) + + it('builds reviewer rows for requested and reviewed users', () => { + expect( + getGitHubPRReviewerRows( + item({ + reviewRequests: [{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }], + latestReviews: [ + { login: 'reviewer', state: 'APPROVED', avatarUrl: 'avatar-2' }, + { login: 'ExampleReviewer', state: 'COMMENTED', avatarUrl: 'avatar-1b' } + ] + }) + ) + ).toEqual([ + { + login: 'ExampleReviewer', + name: null, + avatarUrl: 'avatar-1', + stateLabel: 'Requested' + }, + { + login: 'reviewer', + name: null, + avatarUrl: 'avatar-2', + stateLabel: 'Approved' + } + ]) + }) + + it('appends requested reviewers without duplicating existing logins', () => { + expect( + appendGitHubPRRequestedReviewers( + [{ login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }], + ['examplereviewer', '@new-reviewer'] + ) + ).toEqual([ + { login: 'ExampleReviewer', name: null, avatarUrl: 'avatar-1' }, + { login: 'new-reviewer', name: null, avatarUrl: '' } + ]) + }) + + it('normalizes reviewer input before sending it to GitHub', () => { + expect( + normalizeGitHubReviewerLogins( + [' @ExampleReviewer ', 'examplereviewer', '@new-reviewer'], + new Set(['existing']) + ) + ).toEqual(['ExampleReviewer', 'new-reviewer']) + }) +}) diff --git a/src/renderer/src/components/github-pr-reviewer-display.ts b/src/renderer/src/components/github-pr-reviewer-display.ts new file mode 100644 index 00000000000..1988b0c42a4 --- /dev/null +++ b/src/renderer/src/components/github-pr-reviewer-display.ts @@ -0,0 +1,164 @@ +import type { GitHubAssignableUser, GitHubWorkItem } from '../../../shared/types' + +type ReviewDisplayItem = Pick +export type GitHubPRPrimaryReviewer = Pick & { + name?: string | null +} +export type GitHubPRReviewerRow = GitHubPRPrimaryReviewer & { + stateLabel: string +} + +function uniqueLogins(logins: readonly (string | null | undefined)[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const login of logins) { + const trimmed = login?.trim() + if (!trimmed) { + continue + } + const key = trimmed.toLowerCase() + if (seen.has(key)) { + continue + } + seen.add(key) + result.push(trimmed) + } + return result +} + +export function normalizeGitHubReviewerLogins( + logins: readonly string[], + excludedLogins: ReadonlySet = new Set() +): string[] { + return uniqueLogins(logins.map((login) => login.trim().replace(/^@/, ''))).filter( + (login) => !excludedLogins.has(login.toLowerCase()) + ) +} + +function formatReviewerLogins(logins: readonly string[]): string | null { + if (logins.length === 0) { + return null + } + if (logins.length === 1) { + return logins[0] + } + return `${logins[0]} +${logins.length - 1}` +} + +function formatReviewState(state: string | null | undefined): string { + switch (state) { + case 'APPROVED': + return 'Approved' + case 'CHANGES_REQUESTED': + return 'Changes requested' + case 'COMMENTED': + return 'Commented' + case 'DISMISSED': + return 'Dismissed' + case 'PENDING': + return 'Pending' + default: + return 'Reviewed' + } +} + +export function getGitHubPRReviewLabel(item: ReviewDisplayItem): string { + if ( + item.reviewDecision === undefined && + item.reviewRequests === undefined && + item.latestReviews === undefined + ) { + return 'Reviewers' + } + if (item.reviewDecision === 'APPROVED') { + return 'Approved' + } + if (item.reviewDecision === 'CHANGES_REQUESTED') { + return 'Changes requested' + } + const requestedLabel = formatReviewerLogins( + uniqueLogins((item.reviewRequests ?? []).map((user) => user.login)) + ) + if (requestedLabel) { + return requestedLabel + } + const reviewedLabel = formatReviewerLogins( + uniqueLogins((item.latestReviews ?? []).map((review) => review.login)) + ) + if (reviewedLabel) { + return reviewedLabel + } + return 'No reviewers' +} + +export function getGitHubPRPrimaryReviewer( + item: ReviewDisplayItem +): GitHubPRPrimaryReviewer | null { + const requested = (item.reviewRequests ?? []).find((user) => user.login.trim()) + if (requested) { + return requested + } + const reviewed = (item.latestReviews ?? []).find((review) => review.login.trim()) + if (reviewed) { + return { + login: reviewed.login, + avatarUrl: reviewed.avatarUrl ?? '', + name: null + } + } + return null +} + +export function getGitHubPRReviewerRows(item: ReviewDisplayItem): GitHubPRReviewerRow[] { + const byLogin = new Map() + for (const user of item.reviewRequests ?? []) { + const login = user.login.trim() + if (!login) { + continue + } + byLogin.set(login.toLowerCase(), { + login, + name: user.name, + avatarUrl: user.avatarUrl, + stateLabel: 'Requested' + }) + } + for (const review of item.latestReviews ?? []) { + const login = review.login.trim() + const key = login.toLowerCase() + if (!login || byLogin.has(key)) { + continue + } + byLogin.set(key, { + login, + name: null, + avatarUrl: review.avatarUrl ?? '', + stateLabel: formatReviewState(review.state) + }) + } + return Array.from(byLogin.values()) +} + +export function appendGitHubPRRequestedReviewers( + current: readonly GitHubAssignableUser[], + logins: readonly string[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of current) { + const login = user.login.trim() + if (login) { + byLogin.set(login.toLowerCase(), user) + } + } + for (const rawLogin of logins) { + const login = rawLogin.trim().replace(/^@/, '') + if (!login) { + continue + } + const key = login.toLowerCase() + if (!byLogin.has(key)) { + byLogin.set(key, { login, name: null, avatarUrl: '' }) + } + } + return Array.from(byLogin.values()) +} diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 627b66c1d82..e76fa769362 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -13,6 +13,11 @@ import type { GitHubProjectRow as GitHubProjectRowType } from '../../../../shared/github-project-types' +const PROJECT_FROZEN_COLUMN_SURFACE_CLASS = + '[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]' +const PROJECT_FROZEN_COLUMN_HOVER_SURFACE_CLASS = + 'group-hover/project-row:[background:color-mix(in_srgb,var(--accent)_60%,var(--background))]' + type Props = { row: GitHubProjectRowType fields: GitHubProjectField[] @@ -55,15 +60,32 @@ export default function ProjectRow({ const rowInner = (
{fields.map((f, idx) => { const next = fields[idx + 1] + const frozen = idx < 2 return ( -
+
> +): string { + // Why: the first two columns are frozen during horizontal scroll, so their + // actual widths must be deterministic for the second sticky offset. + const cols = fields.map((field, index) => + index < 2 + ? `${resolveWidth(field, widths)}px` + : `minmax(${MIN_COLUMN_WIDTH}px, ${resolveWidth(field, widths)}fr)` + ) + cols.push(`${ACTION_COLUMN_WIDTH}px`) + return cols.join(' ') +} + type Props = { table: GitHubProjectTable onOpenDialog?: (row: GitHubProjectRow) => void @@ -94,7 +112,16 @@ export default function ProjectViewList({ [scopeKey] ) - const gridTemplate = useMemo(() => buildGridTemplate(fields, widths), [fields, widths]) + const gridTemplate = useMemo(() => buildProjectGridTemplate(fields, widths), [fields, widths]) + + const handleListScroll = useCallback((event: React.UIEvent): void => { + // Why: frozen columns need the horizontal offset, but piping every scroll + // tick through React state rerenders the entire project row set. + event.currentTarget.style.setProperty( + '--project-scroll-left', + `${event.currentTarget.scrollLeft}px` + ) + }, []) const toggleColumn = (fieldId: string): void => { setHidden((prev) => { @@ -167,7 +194,11 @@ export default function ProjectViewList({ : null return ( -
+
+
@@ -899,7 +909,7 @@ function ProjectSearchInput({ }, []) return ( -
+
{ + it('contains long PR body markdown inside its available width', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('min-w-0') + expect(markup).toContain('max-w-full') + expect(markup).toContain('[overflow-wrap:anywhere]') + expect(markup).toContain('overflow-x-auto') + }) +}) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 2ac38022b4f..62dc509bc97 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -38,11 +38,13 @@ const compactComponents: Components = { // more reliable than checking `className` — which is only set when // the fenced block specifies a language (```js), not for bare ```. code: ({ children }) => ( - {children} + + {children} + ), // Compact pre blocks — no syntax highlighting needed for short comments pre: ({ children }) => ( -
+    
       {children}
     
), @@ -90,7 +92,7 @@ const compactComponents: Components = { // overflow container keeps the card layout stable while still letting the // user scroll to see the full table. table: ({ children }) => ( -
+
{children}
@@ -112,10 +114,12 @@ const documentComponents: Components = { ), code: ({ children }) => ( - {children} + + {children} + ), pre: ({ children }) => ( -
+    
       {children}
     
), @@ -207,6 +211,7 @@ const CommentMarkdown = React.memo( // The descendant selector (pre code) has higher specificity than the // direct utility classes on , so these overrides win reliably. '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none', + 'min-w-0 max-w-full [overflow-wrap:anywhere]', className )} {...rest} diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 2a9c735502d..5c73332c7af 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -7,6 +7,7 @@ import { buildTaskPageRepoSourceState, findTaskPageDialogWorkItem, findTaskPageLinearDrawerIssue, + reconcileTaskPagePagesWithWorkItemsCache, selectTaskPageWorkItemsCacheEntries } from './task-page-cache-selectors' @@ -73,6 +74,26 @@ describe('task page cache selectors', () => { expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2' })).toBeNull() }) + it('reconciles paged table rows with patched work-item cache entries', () => { + const stale = { + ...workItem('pr-1', 'repo-1'), + reviewRequests: [] + } + const patched = { + ...stale, + reviewRequests: [{ login: 'AmethystLiang', name: null, avatarUrl: '' }] + } + const otherRepoSameId = workItem('pr-1', 'repo-2') + const pages = [[stale, otherRepoSameId]] + + const nextPages = reconcileTaskPagePagesWithWorkItemsCache(pages, [ + entry([patched]) + ]) + + expect(nextPages[0][0]).toBe(patched) + expect(nextPages[0][1]).toBe(otherRepoSameId) + }) + it('returns null while the Linear drawer is closed and finds open issues by stable reference', () => { const issue = linearIssue('LIN-1') const searchIssue = linearIssue('LIN-2') diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 816be6cc66e..99bda1ffb0c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -51,6 +51,39 @@ export function buildTaskPageRepoSourceState( }) } +function taskPageWorkItemCacheKey(item: GitHubWorkItem): string { + return `${item.repoId}\u0000${item.id}` +} + +export function reconcileTaskPagePagesWithWorkItemsCache( + pages: readonly GitHubWorkItem[][], + entries: readonly (CacheEntry | undefined)[] +): GitHubWorkItem[][] { + const cachedItems = new Map() + for (const entry of entries) { + for (const item of entry?.data ?? []) { + cachedItems.set(taskPageWorkItemCacheKey(item), item) + } + } + + let changed = false + const nextPages = pages.map((page) => { + let pageChanged = false + const nextPage = page.map((item) => { + const cached = cachedItems.get(taskPageWorkItemCacheKey(item)) + if (!cached || cached === item) { + return item + } + pageChanged = true + changed = true + return cached + }) + return pageChanged ? nextPage : page + }) + + return changed ? nextPages : (pages as GitHubWorkItem[][]) +} + export function findTaskPageDialogWorkItem( workItemsCache: WorkItemsCache, dialogWorkItemKey: TaskPageDialogWorkItemKey diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 8b0dc76d28e..44285903251 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import { createGitHubSlice, workItemsCacheKey } from './github' import type { AppState } from '../types' -import type { PRInfo } from '../../../../shared/types' +import type { GitHubWorkItem, PRInfo } from '../../../../shared/types' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -163,6 +163,49 @@ describe('createGitHubSlice.evictGitHubRepoCaches', () => { }) }) +describe('createGitHubSlice.patchWorkItem', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('can scope patches to one repo when different repos have the same work-item id', () => { + const store = createTestStore() + const repoOneItem = { + id: 'pr:42', + repoId: 'repo-1', + type: 'pr', + number: 42, + title: 'Repo one PR' + } as GitHubWorkItem + const repoTwoItem = { + id: 'pr:42', + repoId: 'repo-2', + type: 'pr', + number: 42, + title: 'Repo two PR' + } as GitHubWorkItem + + store.setState({ + workItemsCache: { + [workItemsCacheKey('repo-1', 20, '')]: { data: [repoOneItem], fetchedAt: 1 }, + [workItemsCacheKey('repo-2', 20, '')]: { data: [repoTwoItem], fetchedAt: 1 } + } + }) + + store.getState().patchWorkItem('pr:42', { reviewRequests: [] }, 'repo-1') + + const state = store.getState() + const repoOnePatched = state.workItemsCache[workItemsCacheKey('repo-1', 20, '')]?.data?.[0] + const repoTwoPatched = state.workItemsCache[workItemsCacheKey('repo-2', 20, '')]?.data?.[0] + expect(repoOnePatched).toMatchObject({ + repoId: 'repo-1', + reviewRequests: [] + }) + expect(repoTwoPatched).toBe(repoTwoItem) + }) +}) + describe('createGitHubSlice.fetchPRChecks', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index c9423825ad7..e4227b89ead 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -585,7 +585,7 @@ export type GitHubSlice = { * "new workspace" buttons) to warm the cache before the page mounts. */ prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void - patchWorkItem: (itemId: string, patch: Partial) => void + patchWorkItem: (itemId: string, patch: Partial, repoId?: string | null) => void /** * Monotonic counter bumped whenever a repo's issue-source preference is * flipped. Subscribers (TaskPage's fetch effect) include this in their @@ -1663,7 +1663,7 @@ export const createGitHubSlice: StateCreator = (s } }, - patchWorkItem: (itemId, patch) => { + patchWorkItem: (itemId, patch, repoId) => { set((s) => { const nextCache = { ...s.workItemsCache } let changed = false @@ -1672,7 +1672,11 @@ export const createGitHubSlice: StateCreator = (s if (!entry?.data) { continue } - const idx = entry.data.findIndex((item) => item.id === itemId) + // Why: GitHub issue/PR ids are only unique within a repo. Cross-repo + // task views can contain the same `pr:42` id from multiple repos. + const idx = entry.data.findIndex( + (item) => item.id === itemId && (!repoId || item.repoId === repoId) + ) if (idx === -1) { continue } From ac83111763db5a87886da0cf48775755fbbede13 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 18 May 2026 23:37:38 -0700 Subject: [PATCH 10/20] fix: preserve terminal scroll on worktree switch Capture and restore numeric xterm viewport positions around visible terminal resume during worktree switches. --- .../use-terminal-pane-global-effects.test.ts | 27 +++- .../use-terminal-pane-global-effects.ts | 19 +++ .../src/lib/pane-manager/pane-scroll.test.ts | 135 ++++++++++++++++++ .../src/lib/pane-manager/pane-scroll.ts | 47 ++++++ 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-scroll.test.ts diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index c3c95830e8d..2124e20a00b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' const mocks = vi.hoisted(() => ({ + captureScrollViewportPosition: vi.fn(), fitAndFocusPanes: vi.fn(), fitPanes: vi.fn(), flushTerminalOutput: vi.fn(), - handleTerminalFileDrop: vi.fn() + handleTerminalFileDrop: vi.fn(), + restoreScrollViewportPosition: vi.fn() })) vi.mock('react', async (importOriginal) => { @@ -29,6 +31,11 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ flushTerminalOutput: mocks.flushTerminalOutput })) +vi.mock('@/lib/pane-manager/pane-scroll', () => ({ + captureScrollViewportPosition: mocks.captureScrollViewportPosition, + restoreScrollViewportPosition: mocks.restoreScrollViewportPosition +})) + vi.mock('./terminal-drop-handler', () => ({ handleTerminalFileDrop: mocks.handleTerminalFileDrop })) @@ -128,6 +135,13 @@ describe('useTerminalPaneGlobalEffects', () => { mocks.flushTerminalOutput.mockImplementation((terminal: { name: string }) => { order.push(`flush:${terminal.name}`) }) + mocks.captureScrollViewportPosition.mockImplementation((terminal: { name: string }) => { + order.push(`capture:${terminal.name}`) + return { terminalName: terminal.name } + }) + mocks.restoreScrollViewportPosition.mockImplementation((terminal: { name: string }) => { + order.push(`restore:${terminal.name}`) + }) mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus')) const isActiveRef = { current: false } @@ -145,7 +159,16 @@ describe('useTerminalPaneGlobalEffects', () => { toggleExpandPane: vi.fn() }) - expect(order).toEqual(['flush:terminal-a', 'flush:terminal-b', 'resume', 'fit-focus']) + expect(order).toEqual([ + 'capture:terminal-a', + 'capture:terminal-b', + 'flush:terminal-a', + 'flush:terminal-b', + 'resume', + 'fit-focus', + 'restore:terminal-a', + 'restore:terminal-b' + ]) expect(mocks.fitPanes).not.toHaveBeenCalled() expect(isActiveRef.current).toBe(true) expect(isVisibleRef.current).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index c2ecd12cef1..ed3f4ca8d81 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -15,6 +15,10 @@ import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-sch import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' +import { + captureScrollViewportPosition, + restoreScrollViewportPosition +} from '@/lib/pane-manager/pane-scroll' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -59,6 +63,15 @@ export function useTerminalPaneGlobalEffects({ return } if (isVisible) { + // Why: WebGL resume can disturb xterm's viewport bookkeeping before the + // post-resume fit runs. Capture numeric viewport positions first; the + // restore path avoids content matching so duplicate agent log lines do + // not jump to the wrong history entry. + const viewportPositions = new Map( + manager + .getPanes() + .map((pane) => [pane.id, captureScrollViewportPosition(pane.terminal)] as const) + ) // Why: background PTY output is throttled while a pane is not focused; // flush it before fitting so newly visible terminals paint current state. for (const pane of manager.getPanes()) { @@ -77,6 +90,12 @@ export function useTerminalPaneGlobalEffects({ } else { fitPanes(manager) } + for (const pane of manager.getPanes()) { + const position = viewportPositions.get(pane.id) + if (position) { + restoreScrollViewportPosition(pane.terminal, position) + } + } } else if (wasVisibleRef.current) { // Suspend WebGL when going hidden. xterm.write() continues to land in // the (now DOM-renderer-fallback or paused-canvas) terminal; the diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts new file mode 100644 index 00000000000..becc435407e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Terminal } from '@xterm/xterm' +import { + captureScrollViewportPosition, + restoreScrollViewportPosition, + type ScrollViewportPosition +} from './pane-scroll' + +function createTerminal(args: { + viewportY: number + baseY: number + cols: number + rows: number + type?: 'normal' | 'alternate' +}): Terminal { + const active = { + type: args.type ?? 'normal', + viewportY: args.viewportY, + baseY: args.baseY + } + return { + cols: args.cols, + rows: args.rows, + buffer: { active }, + scrollToBottom: vi.fn(() => { + active.viewportY = active.baseY + }), + scrollToLine: vi.fn((line: number) => { + active.viewportY = line + }), + scrollLines: vi.fn((delta: number) => { + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + delta)) + }) + } as unknown as Terminal +} + +describe('scroll viewport position', () => { + it('captures the numeric viewport position', () => { + const terminal = createTerminal({ viewportY: 42, baseY: 100, cols: 120, rows: 32 }) + + expect(captureScrollViewportPosition(terminal)).toEqual({ + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + cols: 120, + rows: 32 + }) + }) + + it('restores the same viewport line when the terminal grid did not reflow', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100, cols: 120, rows: 32 }) + const state: ScrollViewportPosition = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + cols: 120, + rows: 32 + } + + restoreScrollViewportPosition(terminal, state) + + expect(terminal.scrollToLine).toHaveBeenCalledWith(42) + expect(terminal.buffer.active.viewportY).toBe(42) + }) + + it('clamps the restored viewport line to the current buffer bottom', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 30, cols: 120, rows: 32 }) + const state: ScrollViewportPosition = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + cols: 120, + rows: 32 + } + + restoreScrollViewportPosition(terminal, state) + + expect(terminal.scrollToLine).toHaveBeenCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) + + it('does not restore across normal and alternate buffers', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100, cols: 120, rows: 32 }) + const state: ScrollViewportPosition = { + bufferType: 'alternate', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + cols: 120, + rows: 32 + } + + restoreScrollViewportPosition(terminal, state) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.buffer.active.viewportY).toBe(10) + }) + + it('scrolls to the current bottom when the pane was previously at bottom', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 250, cols: 120, rows: 32 }) + const state: ScrollViewportPosition = { + bufferType: 'normal', + wasAtBottom: true, + viewportY: 100, + baseY: 100, + cols: 120, + rows: 32 + } + + restoreScrollViewportPosition(terminal, state) + + expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1) + expect(terminal.buffer.active.viewportY).toBe(250) + }) + + it('does not numerically restore a non-bottom viewport after a grid reflow', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100, cols: 80, rows: 32 }) + const state: ScrollViewportPosition = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + cols: 120, + rows: 32 + } + + restoreScrollViewportPosition(terminal, state) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.buffer.active.viewportY).toBe(10) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index ff895174c1e..b2345a32716 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,6 +1,15 @@ import type { Terminal } from '@xterm/xterm' import type { ScrollState } from './pane-manager-types' +export type ScrollViewportPosition = { + bufferType: 'normal' | 'alternate' + wasAtBottom: boolean + viewportY: number + baseY: number + cols: number + rows: number +} + // --------------------------------------------------------------------------- // Scroll restoration after reflow // --------------------------------------------------------------------------- @@ -73,6 +82,44 @@ export function restoreScrollState(terminal: Terminal, state: ScrollState): void } } +export function captureScrollViewportPosition(terminal: Terminal): ScrollViewportPosition { + const buf = terminal.buffer.active + return { + bufferType: buf.type, + wasAtBottom: buf.viewportY >= buf.baseY, + viewportY: buf.viewportY, + baseY: buf.baseY, + cols: terminal.cols, + rows: terminal.rows + } +} + +export function restoreScrollViewportPosition( + terminal: Terminal, + state: ScrollViewportPosition +): void { + const buf = terminal.buffer.active + if (buf.type !== state.bufferType) { + return + } + + if (state.wasAtBottom) { + terminal.scrollToBottom() + forceViewportScrollbarSync(terminal) + return + } + + if (terminal.cols !== state.cols || terminal.rows !== state.rows) { + return + } + + // Why: worktree switches can resume WebGL with the same terminal grid but + // stale viewport internals. Restore by numeric viewport only when the grid + // did not reflow, avoiding duplicate-content matching in long agent logs. + terminal.scrollToLine(Math.min(state.viewportY, buf.baseY)) + forceViewportScrollbarSync(terminal) +} + // Why: xterm 6's Viewport._sync() updates scrollDimensions after resize but // skips the scrollPosition update when ydisp matches _latestYDisp (a stale // internal value). This leaves the scrollbar thumb at a wrong position even From 789060c2396a5670cabf571dcd175bba273e05fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 06:39:37 +0000 Subject: [PATCH 11/20] release: v1.4.9-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bc43389a35e..18f5967441a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.8", + "version": "1.4.9-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 833a707182014d54c24ebb2c7685ffe19f8b97bf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 18 May 2026 23:41:02 -0700 Subject: [PATCH 12/20] fix: address review findings (#2311) --- .../editor/ConflictComponents.test.tsx | 130 +++++++++- .../components/editor/ConflictComponents.tsx | 244 ++++++++++++++---- .../src/components/editor/EditorContent.tsx | 199 +++++++++++--- .../editor/useEditorPanelContentState.ts | 15 +- 4 files changed, 487 insertions(+), 101 deletions(-) diff --git a/src/renderer/src/components/editor/ConflictComponents.test.tsx b/src/renderer/src/components/editor/ConflictComponents.test.tsx index 0bca618c0f8..e4d0d23f90c 100644 --- a/src/renderer/src/components/editor/ConflictComponents.test.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.test.tsx @@ -2,7 +2,12 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' import type { GitStatusEntry } from '../../../../shared/types' -import { ConflictReviewPanel } from './ConflictComponents' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + ConflictBanner, + ConflictReviewPanel, + getNextConflictNavigationIndex +} from './ConflictComponents' function createConflictReviewFile(overrides: Partial = {}): OpenFile { return { @@ -46,7 +51,35 @@ function createLiveEntry( } describe('ConflictReviewPanel', () => { - it('renders unresolved conflicts as a left file tree', () => { + it('renders unresolved conflicts as a left tree with supplied default content', () => { + const file = createConflictReviewFile() + const html = renderToStaticMarkup( + file contents
} + onDismiss={vi.fn()} + onRefreshSnapshot={vi.fn()} + onReturnToSourceControl={vi.fn()} + /> + ) + + expect(html).toContain('Files') + expect(html).toContain('Collapse file tree') + expect(html).toContain('src/renderer/src/store/slices') + expect(html).toContain('linear.test.ts') + expect(html).toContain('linear.ts') + expect(html).toContain('data-conflict-review-default-content="true"') + expect(html).toContain('file contents') + expect(html).not.toContain('Select a conflict from the file tree') + }) + + it('renders a lightweight overview when no conflict file is selected', () => { const file = createConflictReviewFile() const html = renderToStaticMarkup( { /> ) - expect(html).toContain('Files') - expect(html).toContain('Collapse file tree') - expect(html).toContain('src/renderer/src/store/slices') - expect(html).toContain('linear.test.ts') - expect(html).toContain('linear.ts') - expect(html).toContain('Select a conflict from the file tree') - expect(html).not.toContain('Choose which version to keep, or combine them') + expect(html).toContain('Conflicts') + expect(html).toContain('Open a file to resolve markers in the editor.') + expect(html).toContain('Choose which version to keep, or combine them') + expect(html).toContain('Resolve the conflict markers') + expect(html).not.toContain('Loading conflict contents') + }) + + it('keeps selected conflict content in a flex-height pane', () => { + const file = createConflictReviewFile() + const html = renderToStaticMarkup( + } + onDismiss={vi.fn()} + onRefreshSnapshot={vi.fn()} + onReturnToSourceControl={vi.fn()} + /> + ) + + expect(html).toContain( + 'class="flex min-h-0 flex-1 flex-col">
{ + it('renders conflict navigation controls when conflict markers are available', () => { + const file = createConflictReviewFile({ + mode: 'edit', + conflict: { + kind: 'conflict-editable', + conflictKind: 'both_modified', + conflictStatus: 'unresolved', + conflictStatusSource: 'git' + } + }) + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('Unresolved conflict') + expect(html).toContain('2 / 3') + expect(html).toContain('Previous conflict') + expect(html).toContain('Next conflict') + }) +}) + +describe('getNextConflictNavigationIndex', () => { + it('cycles through conflicts in both directions', () => { + expect( + getNextConflictNavigationIndex({ currentIndex: null, direction: 'next', total: 3 }) + ).toBe(0) + expect(getNextConflictNavigationIndex({ currentIndex: 2, direction: 'next', total: 3 })).toBe(0) + expect( + getNextConflictNavigationIndex({ currentIndex: 0, direction: 'previous', total: 3 }) + ).toBe(2) + expect( + getNextConflictNavigationIndex({ currentIndex: null, direction: 'previous', total: 3 }) + ).toBe(2) + expect(getNextConflictNavigationIndex({ currentIndex: 0, direction: 'next', total: 0 })).toBe( + null + ) }) }) diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx index b2d00ad65e9..5960fe39278 100644 --- a/src/renderer/src/components/editor/ConflictComponents.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.tsx @@ -1,7 +1,17 @@ import React from 'react' -import { CircleCheck, GitMerge, PanelLeftOpen, RefreshCw, TriangleAlert, X } from 'lucide-react' +import { + ChevronDown, + ChevronUp, + CircleCheck, + GitMerge, + PanelLeftOpen, + RefreshCw, + TriangleAlert, + X +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getFileTypeIcon } from '@/lib/file-type-icons' import { cn } from '@/lib/utils' import type { ConflictReviewEntry, OpenFile } from '@/store/slices/editor' import type { GitConflictKind, GitStatusEntry } from '../../../../shared/types' @@ -29,13 +39,41 @@ export const CONFLICT_HINT_MAP: Record = { const EMPTY_CONFLICT_REVIEW_ENTRIES: readonly ConflictReviewEntry[] = [] let conflictReviewFileTreeCollapsedPreference = false +type ConflictNavigationDirection = 'previous' | 'next' +type ConflictReviewPanelEntry = ConflictReviewEntry & { + liveEntry?: GitStatusEntry +} + +export function getNextConflictNavigationIndex({ + currentIndex, + direction, + total +}: { + currentIndex: number | null + direction: ConflictNavigationDirection + total: number +}): number | null { + if (total <= 0) { + return null + } + if (currentIndex === null || currentIndex < 0 || currentIndex >= total) { + return direction === 'previous' ? total - 1 : 0 + } + return direction === 'previous' ? (currentIndex + total - 1) % total : (currentIndex + 1) % total +} export function ConflictBanner({ file, - entry + entry, + conflictNavigation }: { file: OpenFile entry: GitStatusEntry | null + conflictNavigation?: { + currentIndex: number | null + total: number + onJump: (direction: ConflictNavigationDirection) => void + } }): React.JSX.Element | null { const conflict = file.conflict if (!conflict) { @@ -54,15 +92,58 @@ export function ConflictBanner({ : 'border-emerald-500/20 bg-emerald-500/5' )} > -
- {isUnresolved ? ( - - ) : ( - +
+
+ {isUnresolved ? ( + + ) : ( + + )} + + {label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]} + + {conflictNavigation && conflictNavigation.total > 0 && ( + + {(conflictNavigation.currentIndex ?? 0) + 1} / {conflictNavigation.total} + + )} +
+ {conflictNavigation && conflictNavigation.total > 0 && ( +
+ + + + + + Previous conflict + + + + + + + + Next conflict + + +
)} - - {label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]} -
{/* Why: the hint is omitted here because the file is already open in the editor below. Showing "Open and edit…" or similar would be confusing @@ -104,6 +185,105 @@ export function ConflictPlaceholderView({ file }: { file: OpenFile }): React.JSX ) } +function ConflictReviewOverview({ + entries, + resolvedCount, + onOpenEntry, + onReturnToSourceControl, + onDismiss +}: { + entries: readonly ConflictReviewPanelEntry[] + resolvedCount: number + onOpenEntry: (entry: GitStatusEntry) => void + onReturnToSourceControl: () => void + onDismiss: () => void +}): React.JSX.Element { + return ( +
+
+
+
+
Conflicts
+
+ Open a file to resolve markers in the editor. +
+
+
+ + +
+
+ {resolvedCount > 0 && ( +
+ {resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git. +
+ )} +
+ {entries.map((entry) => { + const FileIcon = getFileTypeIcon(entry.path) + const liveEntry = entry.liveEntry + const isUnresolved = liveEntry?.conflictStatus === 'unresolved' + const statusLabel = isUnresolved ? 'Unresolved' : liveEntry ? 'Resolved' : 'Gone' + + return ( + + ) + })} +
+
+
+ ) +} + export function ConflictReviewPanel({ file, liveEntries, @@ -131,7 +311,7 @@ export function ConflictReviewPanel({ () => new Map(liveEntries.map((entry) => [entry.path, entry])), [liveEntries] ) - const treeEntries = React.useMemo( + const treeEntries = React.useMemo( () => snapshotEntries.map((entry) => ({ ...entry, @@ -219,41 +399,15 @@ export function ConflictReviewPanel({ Refresh
-
+
{selectedContent ?? ( -
-
-
- -
-
- Select a conflict from the file tree -
-
- Open each unresolved file, edit the conflict markers, then refresh this snapshot. -
- {resolvedCount > 0 && ( -
- {resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git. -
- )} -
- - -
-
-
+ )}
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 3107d9ecbc4..b83575666c1 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -10,7 +10,12 @@ import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { ChangesModeView } from './ChangesModeView' -import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents' +import { + ConflictBanner, + ConflictPlaceholderView, + ConflictReviewPanel, + getNextConflictNavigationIndex +} from './ConflictComponents' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' @@ -19,6 +24,7 @@ import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter' import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' import { useMarkdownDocuments } from './useMarkdownDocuments' +import { findGitConflictBlocks } from './monaco-conflict-decorations' const MonacoEditor = lazy(() => import('./MonacoEditor')) const DiffViewer = lazy(() => import('./DiffViewer')) @@ -146,6 +152,10 @@ export function EditorContent({ const openConflictReview = useAppStore((s) => s.openConflictReview) const closeFile = useAppStore((s) => s.closeFile) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) + const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) + const [conflictNavigationIndexByFile, setConflictNavigationIndexByFile] = React.useState< + Record + >({}) const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave) const activeConflictEntry = worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null @@ -160,6 +170,68 @@ export function EditorContent({ activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') + const getConflictNavigation = React.useCallback( + (file: OpenFile, content: string) => { + const blocks = findGitConflictBlocks(content) + if (blocks.length === 0) { + return undefined + } + + const currentIndex = conflictNavigationIndexByFile[file.id] ?? null + return { + currentIndex, + total: blocks.length, + onJump: (direction: 'previous' | 'next') => { + const nextIndex = getNextConflictNavigationIndex({ + currentIndex, + direction, + total: blocks.length + }) + if (nextIndex === null) { + return + } + const line = blocks[nextIndex].startLine + const markerLine = content.split(/\r?\n/)[line - 1] ?? '' + setConflictNavigationIndexByFile((prev) => ({ ...prev, [file.id]: nextIndex })) + // Why: a same-location reveal can be requested twice before Monaco + // consumes the first one. Clearing first guarantees the prop changes + // and the mounted editor runs its reveal effect again. + setPendingEditorReveal(null) + queueMicrotask(() => { + setPendingEditorReveal({ + filePath: file.filePath, + line, + column: 1, + matchLength: markerLine.length + }) + }) + } + } + }, + [conflictNavigationIndexByFile, setPendingEditorReveal] + ) + const openConflictEntry = React.useCallback( + (entry: GitStatusEntry) => { + if (activeFile.mode !== 'conflict-review') { + return + } + openConflictReviewFile( + activeFile.id, + activeFile.worktreeId, + activeFile.filePath, + entry, + detectLanguage(entry.path) + ) + }, + [ + activeFile.filePath, + activeFile.id, + activeFile.mode, + activeFile.worktreeId, + openConflictReviewFile + ] + ) + const renderMonacoEditor = (fc: FileContent): React.JSX.Element => ( // Why: Without a key, React reuses the same MonacoEditor instance when // switching tabs or split panes, just updating props. That means @@ -324,77 +396,105 @@ export function EditorContent({ return
{renderMonacoEditor(fc)}
} - const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => { - if (selectedFile.conflict?.kind === 'conflict-placeholder') { - return + const renderConflictReviewEditorContent = ({ + contentFile, + entry, + className, + viewStateKeySuffix + }: { + contentFile: OpenFile + entry: GitStatusEntry | null + className: string + viewStateKeySuffix: string + }): React.JSX.Element => { + if (contentFile.conflict?.kind === 'conflict-placeholder') { + return ( +
+ +
+ ) } - const fc = fileContents[selectedFile.id] + const fc = fileContents[contentFile.id] if (!fc) { return ( -
- Loading... +
+
+ Loading... +
) } if (fc.loadError) { return ( - reloadFileContent(selectedFile)} /> +
+ reloadFileContent(contentFile)} + /> +
) } if (fc.isBinary) { if (fc.isImage) { return ( - +
+ +
) } return ( -
- Binary file — cannot display +
+
+ Binary file — cannot display +
) } - const selectedConflictEntry = - worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null - const selectedLanguage = detectLanguage(selectedFile.relativePath) + const selectedLanguage = detectLanguage(contentFile.relativePath) const monacoSelectedLanguage = selectedLanguage === 'notebook' ? 'json' : selectedLanguage - const selectedViewStateKey = `${selectedFile.filePath}::${viewStateScopeId}` + const selectedViewStateKey = `${contentFile.filePath}::${viewStateScopeId}:${viewStateKeySuffix}` + const selectedContent = editBuffers[contentFile.id] ?? fc.content return ( -
- {selectedFile.conflict && ( - +
+ {contentFile.conflict && ( + )}
handleContentChangeForFile(selectedFile, content)} - onSave={(content) => handleSaveForFile(selectedFile, content)} - worktreeId={selectedFile.worktreeId} + onContentChange={(content) => handleContentChangeForFile(contentFile, content)} + onSave={(content) => handleSaveForFile(contentFile, content)} + worktreeId={contentFile.worktreeId} markdownAnnotationsEnabled={false} - conflictDecorationsEnabled={selectedFile.conflict?.conflictStatus === 'unresolved'} + conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'} revealLine={ - pendingEditorReveal?.filePath === selectedFile.filePath + pendingEditorReveal?.filePath === contentFile.filePath ? pendingEditorReveal.line : undefined } revealColumn={ - pendingEditorReveal?.filePath === selectedFile.filePath + pendingEditorReveal?.filePath === contentFile.filePath ? pendingEditorReveal.column : undefined } revealMatchLength={ - pendingEditorReveal?.filePath === selectedFile.filePath + pendingEditorReveal?.filePath === contentFile.filePath ? pendingEditorReveal.matchLength : undefined } @@ -404,20 +504,24 @@ export function EditorContent({ ) } + const renderConflictReviewSelectedContent = (selectedFile: OpenFile): React.JSX.Element => { + const selectedConflictEntry = + worktreeEntries.find((entry) => entry.path === selectedFile.relativePath) ?? null + + return renderConflictReviewEditorContent({ + contentFile: selectedFile, + entry: selectedConflictEntry, + className: 'flex min-h-0 flex-1 flex-col', + viewStateKeySuffix: 'selected' + }) + } + if (activeFile.mode === 'conflict-review') { return ( - openConflictReviewFile( - activeFile.id, - activeFile.worktreeId, - activeFile.filePath, - entry, - detectLanguage(entry.path) - ) - } + onOpenEntry={openConflictEntry} selectedFile={selectedConflictReviewFile} selectedContent={ selectedConflictReviewFile @@ -540,7 +644,16 @@ export function EditorContent({ } return (
- {activeFile.conflict && } + {activeFile.conflict && ( + + )}
{isMarkdown ? ( renderMarkdownContent(fc) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 9ef819ec465..acd5d8a64d6 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -81,7 +81,12 @@ export function useEditorPanelContentState({ : null const loadFileContent = useCallback( - async (filePath: string, id: string, worktreeId?: string): Promise => { + async ( + filePath: string, + id: string, + worktreeId?: string, + relativePath?: string + ): Promise => { try { const connectionId = getConnectionId(worktreeId ?? null) ?? undefined const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) @@ -108,7 +113,7 @@ export function useEditorPanelContentState({ pending = readRuntimeFileContent({ settings: readSettings, filePath, - relativePath: restoredOpenFile?.relativePath, + relativePath: restoredOpenFile?.relativePath ?? relativePath, worktreeId, connectionId }) as Promise @@ -246,7 +251,7 @@ export function useEditorPanelContentState({ delete next[file.id] return next }) - void loadFileContent(file.filePath, file.id, file.worktreeId) + void loadFileContent(file.filePath, file.id, file.worktreeId, file.relativePath) }, [loadFileContent] ) @@ -281,8 +286,10 @@ export function useEditorPanelContentState({ activeFile?.id, activeFile?.mode, activeFile?.conflictReview?.selectedFileId, + activeFile?.conflictReview?.snapshotTimestamp, selectedConflictReviewFile?.id, - isChangesMode + isChangesMode, + gitStatusByWorktree ]) useEditorPanelFileLoadRetry({ From ba453a463d818ea201188a06c4dc40ffd21ecb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Tue, 19 May 2026 00:46:24 -0600 Subject: [PATCH 13/20] fix: normalize windows paths in git blob readers (#2290) --- src/main/git/status.test.ts | 12 +++++++++++ src/main/git/status.ts | 8 +++++-- src/relay/git-handler-blob-readers.test.ts | 25 ++++++++++++++++++++++ src/relay/git-handler-ops.ts | 8 +++++-- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 src/relay/git-handler-blob-readers.test.ts diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index b96098e99a9..4c6e815c2a6 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -213,6 +213,18 @@ describe('getDiff', () => { }) }) + it('normalizes Windows separators before reading git blobs', async () => { + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) + readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) + + await getDiff('/repo', 'src\\file.ts', false) + + expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':src/file.ts'], { + cwd: '/repo', + maxBuffer: 10 * 1024 * 1024 + }) + }) + it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => { gitExecFileAsyncBufferMock .mockRejectedValueOnce(new Error('missing index')) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 40ff61d967c..28ba7aabe95 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -811,8 +811,10 @@ async function readGitBlobAtIndexPath( worktreePath: string, filePath: string ): Promise { + // Why: Git's `:` syntax expects forward slashes even on Windows. + const gitPath = filePath.replace(/\\/g, '/') try { - const { stdout } = await gitExecFileAsyncBuffer(['show', `:${filePath}`], { + const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES }) @@ -828,9 +830,11 @@ async function readGitBlobAtOidPath( oid: string, filePath: string ): Promise { + // Why: Git's `:` syntax expects forward slashes even on Windows. + const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer( - ['show', '--end-of-options', `${oid}:${filePath}`], + ['show', '--end-of-options', `${oid}:${gitPath}`], { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts new file mode 100644 index 00000000000..8eea74a689b --- /dev/null +++ b/src/relay/git-handler-blob-readers.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import { readBlobAtIndex, readBlobAtOid, type GitBufferExec } from './git-handler-ops' + +describe('git blob readers', () => { + it('normalizes Windows separators before reading OID blobs', async () => { + const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('head-content')) + + const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'src\\file.ts') + + expect(gitBuffer).toHaveBeenCalledWith( + ['show', '--end-of-options', 'HEAD:src/file.ts'], + '/repo' + ) + expect(result.content).toBe('head-content') + }) + + it('normalizes Windows separators before reading index blobs', async () => { + const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('index-content')) + + const result = await readBlobAtIndex(gitBuffer, '/repo', 'src\\file.ts') + + expect(gitBuffer).toHaveBeenCalledWith(['show', '--end-of-options', ':src/file.ts'], '/repo') + expect(result.content).toBe('index-content') + }) +}) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index a24ff274523..9b7d28aab5d 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -23,8 +23,10 @@ export async function readBlobAtOid( oid: string, filePath: string ): Promise<{ content: string; isBinary: boolean }> { + // Why: Git's `:` syntax expects forward slashes even on Windows. + const gitPath = filePath.replace(/\\/g, '/') try { - const buf = await gitBuffer(['show', `${oid}:${filePath}`], cwd) + const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd) return bufferToBlob(buf, filePath) } catch { return { content: '', isBinary: false } @@ -36,8 +38,10 @@ export async function readBlobAtIndex( cwd: string, filePath: string ): Promise<{ content: string; isBinary: boolean }> { + // Why: Git's `:` syntax expects forward slashes even on Windows. + const gitPath = filePath.replace(/\\/g, '/') try { - const buf = await gitBuffer(['show', `:${filePath}`], cwd) + const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) return bufferToBlob(buf, filePath) } catch { return { content: '', isBinary: false } From 657e620c8810c8ef2ade049afe924b1e44b9ca13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 23:54:38 -0700 Subject: [PATCH 14/20] build(deps): bump brace-expansion from 5.0.5 to 5.0.6 (#2261) Signed-off-by: dependabot[bot] --- pnpm-lock.yaml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a61a895983c..90c5718c407 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3237,8 +3237,8 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -4125,6 +4125,10 @@ packages: 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@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -6811,7 +6815,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -9165,7 +9169,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -10181,6 +10185,13 @@ snapshots: 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 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -11277,7 +11288,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: From 65658fcba6e3a6953b4df50a768ce775968bb6ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 23:54:46 -0700 Subject: [PATCH 15/20] build(deps): bump brace-expansion from 5.0.5 to 5.0.6 in /mobile (#2253) Signed-off-by: dependabot[bot] --- mobile/pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 8874abedd29..1ad1081b0ae 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1418,48 +1418,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.47.0': resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.47.0': resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.47.0': resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.47.0': resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.47.0': resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.47.0': resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.47.0': resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.47.0': resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} @@ -1532,48 +1540,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.63.0': resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.63.0': resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.63.0': resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.63.0': resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.63.0': resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.63.0': resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.63.0': resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.63.0': resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} @@ -2035,36 +2051,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0': resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0': resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0': resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0': resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} @@ -4238,24 +4260,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} From 6805f20696b8d5ba8f6d8bc4ffa4ccff50b50372 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 18 May 2026 23:57:11 -0700 Subject: [PATCH 16/20] Trim conflict component render tests (#2313) * test: trim conflict component render tests * Show unresolved conflicts inline in review panel - Replace the conflict review overview list with read-only inline Monaco views - Add auto-height editor rendering so each conflict file fits its contents - Preload unresolved conflict files for the all-conflicts review view --- .../editor/ConflictComponents.test.tsx | 171 +----------------- .../components/editor/ConflictComponents.tsx | 111 +----------- .../src/components/editor/EditorContent.tsx | 81 ++++++++- .../src/components/editor/MonacoEditor.tsx | 53 +++++- .../editor/useEditorPanelContentState.ts | 27 +++ 5 files changed, 157 insertions(+), 286 deletions(-) diff --git a/src/renderer/src/components/editor/ConflictComponents.test.tsx b/src/renderer/src/components/editor/ConflictComponents.test.tsx index e4d0d23f90c..0b5e91e9d94 100644 --- a/src/renderer/src/components/editor/ConflictComponents.test.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.test.tsx @@ -1,172 +1,5 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import type { OpenFile } from '@/store/slices/editor' -import type { GitStatusEntry } from '../../../../shared/types' -import { TooltipProvider } from '@/components/ui/tooltip' -import { - ConflictBanner, - ConflictReviewPanel, - getNextConflictNavigationIndex -} from './ConflictComponents' - -function createConflictReviewFile(overrides: Partial = {}): OpenFile { - return { - id: 'repo::/repo::conflict-review', - filePath: '/repo', - relativePath: 'Conflict Review', - worktreeId: 'repo::/repo', - language: 'text', - isDirty: false, - mode: 'conflict-review', - conflictReview: { - source: 'live-summary', - snapshotTimestamp: Date.UTC(2026, 4, 17, 19, 9, 7), - entries: [ - { - path: 'src/renderer/src/store/slices/linear.test.ts', - conflictKind: 'both_added' - }, - { - path: 'src/renderer/src/store/slices/linear.ts', - conflictKind: 'both_modified' - } - ] - }, - ...overrides - } -} - -function createLiveEntry( - path: string, - status: GitStatusEntry['status'] = 'modified' -): GitStatusEntry { - return { - path, - status, - area: 'unstaged', - conflictKind: path.endsWith('.test.ts') ? 'both_added' : 'both_modified', - conflictStatus: 'unresolved', - conflictStatusSource: 'git' - } -} - -describe('ConflictReviewPanel', () => { - it('renders unresolved conflicts as a left tree with supplied default content', () => { - const file = createConflictReviewFile() - const html = renderToStaticMarkup( - file contents
} - onDismiss={vi.fn()} - onRefreshSnapshot={vi.fn()} - onReturnToSourceControl={vi.fn()} - /> - ) - - expect(html).toContain('Files') - expect(html).toContain('Collapse file tree') - expect(html).toContain('src/renderer/src/store/slices') - expect(html).toContain('linear.test.ts') - expect(html).toContain('linear.ts') - expect(html).toContain('data-conflict-review-default-content="true"') - expect(html).toContain('file contents') - expect(html).not.toContain('Select a conflict from the file tree') - }) - - it('renders a lightweight overview when no conflict file is selected', () => { - const file = createConflictReviewFile() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Conflicts') - expect(html).toContain('Open a file to resolve markers in the editor.') - expect(html).toContain('Choose which version to keep, or combine them') - expect(html).toContain('Resolve the conflict markers') - expect(html).not.toContain('Loading conflict contents') - }) - - it('keeps selected conflict content in a flex-height pane', () => { - const file = createConflictReviewFile() - const html = renderToStaticMarkup( - } - onDismiss={vi.fn()} - onRefreshSnapshot={vi.fn()} - onReturnToSourceControl={vi.fn()} - /> - ) - - expect(html).toContain( - 'class="flex min-h-0 flex-1 flex-col">
{ - it('renders conflict navigation controls when conflict markers are available', () => { - const file = createConflictReviewFile({ - mode: 'edit', - conflict: { - kind: 'conflict-editable', - conflictKind: 'both_modified', - conflictStatus: 'unresolved', - conflictStatusSource: 'git' - } - }) - const html = renderToStaticMarkup( - - - - ) - - expect(html).toContain('Unresolved conflict') - expect(html).toContain('2 / 3') - expect(html).toContain('Previous conflict') - expect(html).toContain('Next conflict') - }) -}) +import { describe, expect, it } from 'vitest' +import { getNextConflictNavigationIndex } from './ConflictComponents' describe('getNextConflictNavigationIndex', () => { it('cycles through conflicts in both directions', () => { diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx index 5960fe39278..8b6ff42f221 100644 --- a/src/renderer/src/components/editor/ConflictComponents.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.tsx @@ -11,7 +11,6 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { getFileTypeIcon } from '@/lib/file-type-icons' import { cn } from '@/lib/utils' import type { ConflictReviewEntry, OpenFile } from '@/store/slices/editor' import type { GitConflictKind, GitStatusEntry } from '../../../../shared/types' @@ -185,105 +184,6 @@ export function ConflictPlaceholderView({ file }: { file: OpenFile }): React.JSX ) } -function ConflictReviewOverview({ - entries, - resolvedCount, - onOpenEntry, - onReturnToSourceControl, - onDismiss -}: { - entries: readonly ConflictReviewPanelEntry[] - resolvedCount: number - onOpenEntry: (entry: GitStatusEntry) => void - onReturnToSourceControl: () => void - onDismiss: () => void -}): React.JSX.Element { - return ( -
-
-
-
-
Conflicts
-
- Open a file to resolve markers in the editor. -
-
-
- - -
-
- {resolvedCount > 0 && ( -
- {resolvedCount} conflict{resolvedCount === 1 ? '' : 's'} no longer live in Git. -
- )} -
- {entries.map((entry) => { - const FileIcon = getFileTypeIcon(entry.path) - const liveEntry = entry.liveEntry - const isUnresolved = liveEntry?.conflictStatus === 'unresolved' - const statusLabel = isUnresolved ? 'Unresolved' : liveEntry ? 'Resolved' : 'Gone' - - return ( - - ) - })} -
-
-
- ) -} - export function ConflictReviewPanel({ file, liveEntries, @@ -323,7 +223,6 @@ export function ConflictReviewPanel({ (entry) => entry.liveEntry?.conflictStatus === 'unresolved' ) const unresolvedCount = unresolvedSnapshotEntries.length - const resolvedCount = Math.max(0, snapshotEntries.length - unresolvedCount) const snapshotTime = new Date( file.conflictReview?.snapshotTimestamp ?? Date.now() ).toLocaleTimeString() @@ -401,13 +300,9 @@ export function ConflictReviewPanel({
{selectedContent ?? ( - +
+ Loading conflict contents... +
)}
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index b83575666c1..7aa784007b5 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -7,6 +7,7 @@ already live in their own modules. */ import React, { lazy } from 'react' import { AlertCircle, RefreshCw } from 'lucide-react' import { detectLanguage } from '@/lib/language-detect' +import { joinPath } from '@/lib/path' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { ChangesModeView } from './ChangesModeView' @@ -232,6 +233,40 @@ export function EditorContent({ ] ) + const createConflictReviewContentFile = (entry: GitStatusEntry): OpenFile => { + const absolutePath = joinPath(activeFile.filePath, entry.path) + const conflict = + entry.conflictKind && entry.conflictStatus && entry.conflictStatusSource + ? entry.status === 'deleted' + ? { + kind: 'conflict-placeholder' as const, + conflictKind: entry.conflictKind, + conflictStatus: entry.conflictStatus, + conflictStatusSource: entry.conflictStatusSource, + message: + 'This file is in a conflict state, but no working-tree file is available to edit.', + guidance: 'Resolve the conflict in Git or restore one side before reopening it.' + } + : { + kind: 'conflict-editable' as const, + conflictKind: entry.conflictKind, + conflictStatus: entry.conflictStatus, + conflictStatusSource: entry.conflictStatusSource + } + : undefined + + return { + id: absolutePath, + filePath: absolutePath, + relativePath: entry.path, + worktreeId: activeFile.worktreeId, + language: detectLanguage(entry.path), + isDirty: false, + mode: 'edit', + conflict + } + } + const renderMonacoEditor = (fc: FileContent): React.JSX.Element => ( // Why: Without a key, React reuses the same MonacoEditor instance when // switching tabs or split panes, just updating props. That means @@ -400,12 +435,16 @@ export function EditorContent({ contentFile, entry, className, - viewStateKeySuffix + viewStateKeySuffix, + readOnly = false, + autoHeight = false }: { contentFile: OpenFile entry: GitStatusEntry | null className: string viewStateKeySuffix: string + readOnly?: boolean + autoHeight?: boolean }): React.JSX.Element => { if (contentFile.conflict?.kind === 'conflict-placeholder') { return ( @@ -470,7 +509,7 @@ export function EditorContent({ conflictNavigation={getConflictNavigation(contentFile, selectedContent)} /> )} -
+
handleContentChangeForFile(contentFile, content)} - onSave={(content) => handleSaveForFile(contentFile, content)} + onContentChange={ + readOnly ? () => {} : (content) => handleContentChangeForFile(contentFile, content) + } + onSave={readOnly ? () => {} : (content) => handleSaveForFile(contentFile, content)} worktreeId={contentFile.worktreeId} markdownAnnotationsEnabled={false} conflictDecorationsEnabled={contentFile.conflict?.conflictStatus === 'unresolved'} + readOnly={readOnly} + autoHeight={autoHeight} revealLine={ pendingEditorReveal?.filePath === contentFile.filePath ? pendingEditorReveal.line @@ -516,6 +559,34 @@ export function EditorContent({ }) } + const renderConflictReviewInlineFile = (entry: GitStatusEntry): React.JSX.Element => { + const contentFile = createConflictReviewContentFile(entry) + + return renderConflictReviewEditorContent({ + contentFile, + entry, + className: 'flex min-h-[120px] flex-col border-b border-border last:border-b-0', + viewStateKeySuffix: `overview:${entry.path}`, + readOnly: true, + autoHeight: true + }) + } + + const renderConflictReviewAllContent = (): React.JSX.Element => { + const snapshotEntries = activeFile.conflictReview?.entries ?? [] + const liveEntriesByPath = new Map(worktreeEntries.map((entry) => [entry.path, entry])) + const unresolvedEntries = snapshotEntries.flatMap((entry) => { + const liveEntry = liveEntriesByPath.get(entry.path) + return liveEntry?.conflictStatus === 'unresolved' && liveEntry.conflictKind ? [liveEntry] : [] + }) + + return ( +
+ {unresolvedEntries.map(renderConflictReviewInlineFile)} +
+ ) + } + if (activeFile.mode === 'conflict-review') { return ( closeFile(activeFile.id)} onRefreshSnapshot={() => diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 155132b23a3..a78948f3d45 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -53,6 +53,8 @@ type MonacoEditorProps = { worktreeId?: string markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean + readOnly?: boolean + autoHeight?: boolean } export default function MonacoEditor({ @@ -69,11 +71,14 @@ export default function MonacoEditor({ markdownDocuments, worktreeId, markdownAnnotationsEnabled = false, - conflictDecorationsEnabled = false + conflictDecorationsEnabled = false, + readOnly = false, + autoHeight = false }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) const editorContainerRef = useRef(null) const [mountedEditor, setMountedEditor] = useState(null) + const [autoHeightContentHeight, setAutoHeightContentHeight] = useState(null) const modelKeyRef = useRef(null) const languageRef = useRef(language) languageRef.current = language @@ -113,6 +118,16 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + const estimatedAutoHeight = useMemo(() => { + if (!autoHeight) { + return null + } + const lineHeight = Math.ceil(editorFontSize * 1.45) + return Math.max(80, content.split(/\r?\n/).length * lineHeight + 18) + }, [autoHeight, content, editorFontSize]) + const renderedEditorHeight = autoHeight + ? (autoHeightContentHeight ?? estimatedAutoHeight ?? 80) + : null // Why: `keepCurrentModel` retains Monaco models across unmounts, and // @monaco-editor/react skips its value→model sync on the first render after // a remount. Without explicit sync, external file changes that arrived @@ -279,6 +294,24 @@ export default function MonacoEditor({ (editorInstance, monaco) => { editorRef.current = editorInstance setMountedEditor(editorInstance) + let autoHeightSub: { dispose: () => void } | null = null + let autoHeightFrame: number | null = null + const updateAutoHeight = (): void => { + if (!autoHeight) { + return + } + if (autoHeightFrame !== null) { + return + } + autoHeightFrame = window.requestAnimationFrame(() => { + autoHeightFrame = null + setAutoHeightContentHeight(Math.ceil(editorInstance.getContentHeight()) + 1) + }) + } + if (autoHeight) { + updateAutoHeight() + autoHeightSub = editorInstance.onDidContentSizeChange(updateAutoHeight) + } markdownDocLinkDecorationsRef.current = createMarkdownDocLinkDecorationController( editorInstance, () => languageRef.current @@ -337,6 +370,11 @@ export default function MonacoEditor({ }) editorInstance.onDidDispose(() => { + autoHeightSub?.dispose() + if (autoHeightFrame !== null) { + window.cancelAnimationFrame(autoHeightFrame) + autoHeightFrame = null + } conflictDecorationsRef.current?.clear() conflictDecorationsRef.current = null editorRef.current = null @@ -413,7 +451,8 @@ export default function MonacoEditor({ filePath, setEditorCursorLine, updateMarkdownCompletionDocuments, - viewStateKey + viewStateKey, + autoHeight ] ) @@ -601,7 +640,11 @@ export default function MonacoEditor({ }, [queueReveal, revealLine, revealColumn, revealMatchLength, setPendingEditorReveal]) return ( -
+
{commentPopover && shouldShowMarkdownAnnotations && ( )} { + if (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile) { + const snapshotEntries = activeFile.conflictReview?.entries ?? [] + if (snapshotEntries.length === 0) { + return + } + + const snapshotPaths = new Set(snapshotEntries.map((entry) => entry.path)) + const liveEntries = gitStatusByWorktree[activeFile.worktreeId] ?? [] + for (const entry of liveEntries) { + if ( + !snapshotPaths.has(entry.path) || + entry.conflictStatus !== 'unresolved' || + !entry.conflictKind || + entry.status === 'deleted' + ) { + continue + } + + const absolutePath = joinPath(activeFile.filePath, entry.path) + if (!fileContents[absolutePath]) { + void loadFileContent(absolutePath, absolutePath, activeFile.worktreeId, entry.path) + } + } + return + } + const fileToLoad = selectedConflictReviewFile ?? activeFile if (!fileToLoad || (activeFile?.mode === 'conflict-review' && !selectedConflictReviewFile)) { return From 32866da8f07c288cdb4c6ab2d3d6b7be5c29ddc6 Mon Sep 17 00:00:00 2001 From: Yuzerion Date: Tue, 19 May 2026 14:57:42 +0800 Subject: [PATCH 17/20] Fix SSH browse ls alias handling (#2242) --- src/main/ipc/ssh-browse.test.ts | 84 +++++++++++++++++++++++++++++++++ src/main/ipc/ssh-browse.ts | 8 ++-- 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/main/ipc/ssh-browse.test.ts diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts new file mode 100644 index 00000000000..4d288be2394 --- /dev/null +++ b/src/main/ipc/ssh-browse.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from 'events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerSshBrowseHandler } from './ssh-browse' + +const { handleMock, removeHandlerMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock, + removeHandler: removeHandlerMock + } +})) + +type BrowseHandler = ( + event: unknown, + args: { targetId: string; dirPath: string } +) => Promise + +function createMockChannel(): EventEmitter & { stderr: EventEmitter } { + return Object.assign(new EventEmitter(), { + stderr: new EventEmitter() + }) +} + +describe('registerSshBrowseHandler', () => { + let handler: BrowseHandler + + beforeEach(() => { + handleMock.mockReset() + removeHandlerMock.mockReset() + handleMock.mockImplementation((_channel: string, registeredHandler: BrowseHandler) => { + handler = registeredHandler + }) + }) + + it('bypasses remote ls aliases when listing a directory', async () => { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\nsrc/\nREADME.md\nnotes file.txt\n')) + channel.emit('exit', 0) + channel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: '/home/user', + entries: [ + { name: 'src', isDirectory: true }, + { name: 'notes file.txt', isDirectory: false }, + { name: 'README.md', isDirectory: false } + ] + }) + expect(exec).toHaveBeenCalledWith('cd "$HOME" && pwd && command ls -1Ap') + }) + + it('escapes remote browse paths before invoking command ls', async () => { + const channel = createMockChannel() + const exec = vi.fn().mockResolvedValue(channel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "/tmp/it's here" }) + await Promise.resolve() + channel.emit('data', Buffer.from("/tmp/it's here\n")) + channel.emit('exit', 0) + channel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: "/tmp/it's here", + entries: [] + }) + expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap") + }) +}) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 874e677dbd7..78a7eb1ea94 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -30,14 +30,14 @@ export function registerSshBrowseHandler( throw new Error(`SSH connection "${args.targetId}" not found`) } - // Why: using printf with a delimiter instead of ls avoids issues with - // filenames containing spaces or special characters. The -1 flag outputs - // one entry per line. The -p flag appends / to directories. + // Why: using one line per entry preserves filenames containing spaces. + // `command ls` bypasses user aliases/functions like `ls='eza ...'`. + // The -1 flag outputs one entry per line. The -p flag appends / to directories. // We resolve ~ and get the absolute path via `cd && pwd`. // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission // denied after a readable `cd ... && pwd`) propagates as a non-zero exit // code rather than being indistinguishable from an empty directory. - const command = `cd ${shellEscape(args.dirPath)} && pwd && ls -1ap` + const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap` const channel = await conn.exec(command) return new Promise((resolve, reject) => { From cf614b5f4f97a0522241bf8aa6ca8622fcb19f8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 00:28:55 -0700 Subject: [PATCH 18/20] Fix agent completion notification dedupe (#2304) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 37 ++ src/main/agent-hooks/server.ts | 16 +- src/main/index.ts | 18 +- src/main/ipc/pty.test.ts | 34 ++ src/main/ipc/pty.ts | 11 + .../components/settings/NotificationsPane.tsx | 45 ++ .../agent-completion-coordinator-types.ts | 25 + .../agent-completion-coordinator.test.ts | 410 +++++++++++++++ .../agent-completion-coordinator.ts | 388 ++++++++++++++ .../agent-process-inspection-queue.ts | 80 +++ .../terminal-pane/pty-connection.test.ts | 486 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 88 +++- .../terminal-pane/title-agent-identity.ts | 28 + .../use-notification-dispatch.ts | 129 ++--- ...gent-hook-completion-notifications.test.ts | 93 ++++ .../agent-hook-completion-notifications.ts | 123 +++++ src/renderer/src/hooks/useIpcEvents.ts | 28 +- src/renderer/src/store/slices/ui.ts | 1 + src/shared/agent-hook-endpoint-file.test.ts | 65 +++ src/shared/agent-hook-endpoint-file.ts | 42 ++ src/shared/agent-process-recognition.test.ts | 12 + src/shared/agent-process-recognition.ts | 77 +++ tests/e2e/droid-notification.spec.ts | 204 ++++++++ tests/e2e/helpers/agent-hook-endpoint.ts | 79 +++ tests/e2e/notification-settings.spec.ts | 94 ++++ 25 files changed, 2531 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts create mode 100644 src/renderer/src/components/terminal-pane/title-agent-identity.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-notifications.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-notifications.ts create mode 100644 src/shared/agent-hook-endpoint-file.test.ts create mode 100644 src/shared/agent-hook-endpoint-file.ts create mode 100644 src/shared/agent-process-recognition.test.ts create mode 100644 src/shared/agent-process-recognition.ts create mode 100644 tests/e2e/helpers/agent-hook-endpoint.ts create mode 100644 tests/e2e/notification-settings.spec.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 7df50d5ac83..843e1837938 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2179,6 +2179,43 @@ describe('Endpoint file lifecycle', () => { } }) + it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'development', + userDataPath, + endpointNamespace: 'com.stablyai.orca.dev.test123' + }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123') + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + } finally { + server.stop() + } + }) + + it('keeps endpoint files separate for parallel dev namespaces', async () => { + const firstServer = new AgentHookServer() + const secondServer = new AgentHookServer() + await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' }) + await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' }) + try { + expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath) + expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath) + expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe( + secondServer.endpointFilePath + ) + expect(existsSync(firstServer.endpointFilePath!)).toBe(true) + expect(existsSync(secondServer.endpointFilePath!)).toBe(true) + } finally { + firstServer.stop() + secondServer.stop() + } + }) + it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { // Why: the endpoint file is opt-in via start({ userDataPath }). In tests // and in the packaged main-process path where userData is unset for any diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index db4db9f4449..ce16aa0d65a 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -597,7 +597,11 @@ export class AgentHookServer { this.applyNormalizedStatus(event) } - async start(options?: { env?: string; userDataPath?: string }): Promise { + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { if (this.server) { return } @@ -606,7 +610,12 @@ export class AgentHookServer { this.env = options.env } if (options?.userDataPath) { - this.endpointDir = join(options.userDataPath, 'agent-hooks') + // Why: dev builds share one userData path, so callers can namespace the + // endpoint file by dev instance while packaged builds keep the stable path + // that lets long-lived PTYs reconnect after app restart. + this.endpointDir = options.endpointNamespace + ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) + : join(options.userDataPath, 'agent-hooks') this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) } @@ -781,6 +790,9 @@ export class AgentHookServer { ORCA_AGENT_HOOK_ENV: this.env, ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION } + // Why: managed hooks source this file at invocation time. Packaged builds + // use a stable file for restart handoff; dev callers pass a per-instance + // namespace so parallel `pnpm dev` runs do not steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache } diff --git a/src/main/index.ts b/src/main/index.ts index f7707272982..fa07a5c9fe1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -108,6 +108,9 @@ let watcherShutdownDone = false let automations: AutomationService | null = null const isServeMode = process.argv.includes('--serve') const devInstanceIdentity = getDevInstanceIdentity(is.dev) +const devAgentHookEndpointNamespace = devInstanceIdentity.isDev + ? devInstanceIdentity.appUserModelId + : undefined installUncaughtPipeErrorGuard() // Why: propagate the Orca app version into `process.env` so PTY-env @@ -164,12 +167,11 @@ function focusExistingWindow(): void { // // Why skip in dev: engineers routinely run `pnpm dev` in parallel from // multiple worktrees while shipping features, and the lock makes the second -// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and -// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route -// to whichever instance wrote last). The dev build is not used for real -// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps -// the lock to protect against the corruption documented in PR #1326 / -// issue #1312. +// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` may race +// (the bundled `orca-dev` CLI routes to whichever instance wrote last). Agent +// hook endpoint files are namespaced per dev instance when the hook server +// starts below. Packaged Orca keeps the lock to protect against the corruption +// documented in PR #1326 / issue #1312. const hasSingleInstanceLock = is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow) if (!hasSingleInstanceLock) { @@ -941,7 +943,9 @@ app.whenReady().then(async () => { env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time, so old PTY // env still reaches the current Orca process after an app restart. - userDataPath: app.getPath('userData') + // Dev uses a namespace because all worktrees share `orca-dev`. + userDataPath: app.getPath('userData'), + endpointNamespace: devAgentHookEndpointNamespace }), onDaemonError: (error) => { console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f395e02f3e4..38f15fe68e8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -579,6 +579,40 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips stale inherited hook receiver env before injecting this runtime', async () => { + const env = await spawnAndGetEnv({ + ORCA_AGENT_HOOK_PORT: '1111', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: 'stale-version', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + }) + + expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') + expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + }) + + it('does not leak inherited hook receiver env if the hook server is unavailable', async () => { + buildAgentHookEnvMock.mockReturnValueOnce({}) + + const env = await spawnAndGetEnv({ + ORCA_AGENT_HOOK_PORT: '1111', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: 'stale-version', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + }) + + expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + }) + it('prepends local git/gh attribution shims when attribution is enabled', async () => { const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({ enableGitHubAttribution: true diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 508d0ae04a8..4a1454292d6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -80,6 +80,14 @@ const ptyPaneKey = new Map() // Kept in lock-step with ptyPaneKey via the same spawn and teardown sites. const paneKeyPtyId = new Map() +const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } @@ -305,6 +313,9 @@ export function buildPtyHostEnv( // must inject the loopback receiver coordinates before the agent starts. // Without these env vars the global hook config cannot map callbacks back // to the correct Orca pane. + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete baseEnv[key] + } Object.assign(baseEnv, agentHookServer.buildPtyEnv()) // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 8eaa7f8dbd2..8b89abdce0a 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -47,6 +47,25 @@ type NotificationsPaneProps = { updateSettings: (updates: Partial) => void } +function getRendererNotificationPermission(): NotificationPermission | null { + if (typeof window.Notification === 'undefined') { + return null + } + return window.Notification.permission +} + +function showNotificationPermissionDeniedToast(): void { + toast.error('Notifications are blocked in macOS', { + description: 'Enable notifications for this Orca app in System Settings.', + action: { + label: 'Open Settings', + onClick: () => { + void window.api.notifications.openSystemSettings() + } + } + }) +} + export function NotificationsPane({ settings, updateSettings @@ -64,6 +83,20 @@ export function NotificationsPane({ } const handleSendTestNotification = async (): Promise => { + // Why: Electron main cannot reliably read macOS notification authorization, + // but the renderer exposes it. Without this check, dev builds can report + // "sent" while macOS silently drops the notification. + if (getRendererNotificationPermission() === 'denied') { + showNotificationPermissionDeniedToast() + return + } + + const permissionStatus = await window.api.notifications.getPermissionStatus() + if (!permissionStatus.supported) { + toast.error('Notifications are not supported on this system') + return + } + const result = await window.api.notifications.dispatch({ source: 'test' }) if (result.delivered) { // Why: the Test button must always play through, even if the user clicks @@ -77,7 +110,19 @@ export function NotificationsPane({ return } toast.success('Test notification sent') + return } + + if (getRendererNotificationPermission() === 'denied') { + showNotificationPermissionDeniedToast() + return + } + + toast.error( + result.reason === 'disabled' + ? 'Notifications are disabled' + : 'Test notification was not delivered' + ) } const handleChooseSound = async (): Promise => { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts new file mode 100644 index 00000000000..8df083213fc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -0,0 +1,25 @@ +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { GlobalSettings } from '../../../../shared/types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +export type AgentCompletionCoordinatorOptions = { + paneKey: string + getPtyId: () => string | null + getSettings: () => Pick | null | undefined + inspectProcess: ( + settings: Pick | null | undefined, + ptyId: string + ) => Promise + dispatchCompletion: (title: string) => void + isLive: () => boolean +} + +export type AgentCompletionCoordinator = { + observeTitle: (title: string) => void + observeClassifiedTitleCompletion: (title: string) => void + observeTitleWorking: () => void + observeHookStatus: (payload: ParsedAgentStatusPayload) => void + startProcessTracking: () => void + resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void + dispose: () => void +} diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts new file mode 100644 index 00000000000..a63c27e42b5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -0,0 +1,410 @@ +/* oxlint-disable max-lines */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { resetAgentProcessInspectionQueueForTests } from './agent-process-inspection-queue' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +async function flushAsyncTicks(count = 4): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve() + } +} + +function processResult(foregroundProcess: string | null): RuntimeTerminalProcessInspection { + return { foregroundProcess, hasChildProcesses: foregroundProcess !== null } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred!: (value: T) => void + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + return { promise, resolve: resolveDeferred } +} + +describe('agent completion coordinator', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + afterEach(() => { + resetAgentProcessInspectionQueueForTests() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + foregroundProcess = 'zsh' + vi.advanceTimersByTime(750) + await flushAsyncTicks() + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + dispatchCompletion.mockClear() + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + await flushAsyncTicks() + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('suppresses process-exit backstop after a title completion already notified the turn', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + foregroundProcess = null + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex done') + }) + + it('suppresses same-turn title completion after a hook completion already notified', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.observeClassifiedTitleCompletion('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('ignores stale working title state after a hook completion already notified', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.observeTitle('⠋ codex') + coordinator.observeTitle('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('suppresses delayed title completion after process inspection changes sessions', async () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult('codex')), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.observeClassifiedTitleCompletion('codex done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('suppresses late process-exit backstop after process inspection follows hook completion', async () => { + let foregroundProcess: string | null = 'codex' + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foregroundProcess)), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + foregroundProcess = null + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex') + }) + + it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('can require a fresh working signal after completion state reset', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + coordinator.resetCompletionState({ requireFreshWorking: true }) + coordinator.observeClassifiedTitleCompletion('codex done') + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + coordinator.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it('ignores process inspections that resolve after completion state reset', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + coordinator.resetCompletionState({ requireFreshWorking: true }) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('starts a fresh pending-title inspection after stale inspection resolves', async () => { + const firstInspection = createDeferred() + const secondInspection = createDeferred() + const inspectProcess = vi + .fn() + .mockReturnValueOnce(firstInspection.promise) + .mockReturnValueOnce(secondInspection.promise) + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess, + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + coordinator.resetCompletionState({ requireFreshWorking: true }) + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + firstInspection.resolve(processResult('codex')) + await flushAsyncTicks() + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + secondInspection.resolve(processResult('codex')) + await flushAsyncTicks() + + expect(inspectProcess).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability') + }) + + it('allows later done-only hook completions from the same long-lived process', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'first task', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: 'first task', + agentType: 'codex' + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1_000) + coordinator.observeHookStatus({ + state: 'done', + prompt: 'second task', + agentType: 'codex' + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it.each([ + 'claude', + 'codex', + 'gemini', + 'opencode', + 'cursor', + 'pi', + 'droid', + 'grok', + 'copilot', + 'hermes' + ])('recognizes %s hook agent ids even when the binary name differs', (agentType) => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'done', + prompt: '', + agentType + }) + + expect(dispatchCompletion).toHaveBeenCalledWith(agentType) + }) + + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { + const inspection = createDeferred() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'remote:terminal-1', + getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }), + inspectProcess: vi.fn(() => inspection.promise), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeTitle('⠋ experimental-agent-observability') + coordinator.observeTitle('experimental-agent-observability') + vi.advanceTimersByTime(10_500) + inspection.resolve(processResult('codex')) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts new file mode 100644 index 00000000000..f7b9a2ddf7d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -0,0 +1,388 @@ +/* oxlint-disable max-lines */ +import { detectAgentStatusFromTitle, type AgentStatus } from '../../../../shared/agent-detection' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { + isRecognizedAgentType, + recognizeAgentProcess, + type RecognizedAgentProcess +} from '../../../../shared/agent-process-recognition' +import { + enqueueAgentProcessInspection, + type InspectionPriority +} from './agent-process-inspection-queue' +import type { + AgentCompletionCoordinator, + AgentCompletionCoordinatorOptions +} from './agent-completion-coordinator-types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { + titleHasExplicitAgentIdentity, + titleIsInconclusiveNativeDroidTitle +} from './title-agent-identity' + +type CompletionSource = 'hook' | 'title' | 'process-exit' + +const IDLE_POLL_INTERVAL_MS = 2_000 +const ACTIVE_POLL_INTERVAL_MS = 750 +const INSPECTION_TIMEOUT_MS = 15_000 +const PENDING_TITLE_TTL_MS = Math.max(2_000, INSPECTION_TIMEOUT_MS + 500) +const PENDING_TITLE_MAX_TTL_MS = Math.max(30_000, PENDING_TITLE_TTL_MS) +const COMPLETION_REPLAY_GUARD_MS = 1_000 + +function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean { + return state === 'done' || state === 'waiting' || state === 'blocked' +} + +export function createAgentCompletionCoordinator( + options: AgentCompletionCoordinatorOptions +): AgentCompletionCoordinator { + let disposed = false + let agentIdentityEstablished = false + let hasAgentRunEvidence = false + let workingStatusObserved = false + let lastTitleStatus: AgentStatus | null = null + let currentTurn = 0 + let processSession = 0 + let lastCompletionToken: string | null = null + let lastCompletionAt = 0 + let lastCompletedTurn: number | null = null + let lastCompletionSource: CompletionSource | null = null + let lastForegroundAgent: RecognizedAgentProcess | null = null + let requiresFreshWorking = false + let pollTimer: ReturnType | null = null + let pendingTitleTimer: ReturnType | null = null + let pendingTitle: { + title: string + expiresAt: number + maxExpiresAt: number + firstInspectionFinished: boolean + } | null = null + let inspectionInFlight = false + let inspectionGeneration = 0 + let consecutiveInspectionErrors = 0 + + function clearPollTimer(): void { + if (pollTimer === null) { + return + } + clearTimeout(pollTimer) + pollTimer = null + } + + function clearPendingTitleTimer(): void { + if (pendingTitleTimer === null) { + return + } + clearTimeout(pendingTitleTimer) + pendingTitleTimer = null + } + + function establishAgentEvidence(): void { + agentIdentityEstablished = true + hasAgentRunEvidence = true + dispatchPendingTitleIfEligible() + } + + function clearAgentRunEvidence(): void { + agentIdentityEstablished = false + hasAgentRunEvidence = false + workingStatusObserved = false + dropPendingTitle() + } + + function completionToken(source: CompletionSource): string { + if (workingStatusObserved) { + return `turn:${currentTurn}` + } + if (lastForegroundAgent) { + return `process:${processSession}` + } + return `${source}:${currentTurn}:${processSession}` + } + + function dispatchCompletion(source: CompletionSource, title: string): void { + if (requiresFreshWorking || lastCompletedTurn === currentTurn) { + return + } + if (!options.isLive() || !hasAgentRunEvidence) { + return + } + const now = Date.now() + const token = completionToken(source) + if (token === lastCompletionToken && now - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS) { + return + } + lastCompletionToken = token + lastCompletionAt = now + lastCompletedTurn = currentTurn + lastCompletionSource = source + workingStatusObserved = false + options.dispatchCompletion(title) + } + + function dropPendingTitle(): void { + clearPendingTitleTimer() + pendingTitle = null + } + + function dispatchPendingTitleIfEligible(): void { + if (!pendingTitle || !agentIdentityEstablished || !hasAgentRunEvidence) { + return + } + const title = pendingTitle.title + dropPendingTitle() + dispatchCompletion('title', title) + } + + function schedulePendingTitleExpiry(): void { + clearPendingTitleTimer() + const pending = pendingTitle + if (!pending) { + return + } + const remaining = pending.expiresAt - Date.now() + if (remaining <= 0) { + pendingTitle = null + scheduleNextPoll() + return + } + pendingTitleTimer = setTimeout(() => { + pendingTitleTimer = null + if (!pendingTitle) { + return + } + if (!pendingTitle.firstInspectionFinished && Date.now() < pendingTitle.maxExpiresAt) { + pendingTitle.expiresAt = Math.min(Date.now() + 500, pendingTitle.maxExpiresAt) + schedulePendingTitleExpiry() + return + } + pendingTitle = null + scheduleNextPoll() + }, remaining) + } + + function holdTitleCompletionPending(title: string): void { + const now = Date.now() + // Why: generic spinner titles can be just "⠋ cwd"; hold the completion + // only long enough for one foreground-process probe to prove an agent owns it. + pendingTitle = { + title, + expiresAt: Math.min(now + PENDING_TITLE_TTL_MS, now + PENDING_TITLE_MAX_TTL_MS), + maxExpiresAt: now + PENDING_TITLE_MAX_TTL_MS, + firstInspectionFinished: false + } + schedulePendingTitleExpiry() + requestInspection('pending-title') + } + + function handleRecognizedProcess(process: RecognizedAgentProcess): void { + if (lastForegroundAgent?.agent !== process.agent) { + if (lastForegroundAgent && hasAgentRunEvidence) { + dispatchCompletion('process-exit', lastForegroundAgent.processName) + } + processSession += 1 + } + lastForegroundAgent = process + establishAgentEvidence() + } + + function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): void { + consecutiveInspectionErrors = 0 + const recognized = recognizeAgentProcess(result.foregroundProcess) + if (recognized) { + handleRecognizedProcess(recognized) + } else if (lastForegroundAgent && hasAgentRunEvidence) { + const exited = lastForegroundAgent + dispatchCompletion('process-exit', exited.processName) + lastForegroundAgent = null + clearAgentRunEvidence() + } else { + lastForegroundAgent = null + clearAgentRunEvidence() + } + } + + function requestInspection(priority: InspectionPriority): void { + if (disposed || inspectionInFlight || !options.isLive()) { + return + } + const ptyId = options.getPtyId() + if (!ptyId) { + return + } + inspectionInFlight = true + const generationAtRequest = inspectionGeneration + enqueueAgentProcessInspection({ + priority, + run: async () => { + try { + const result = await options.inspectProcess(options.getSettings(), ptyId) + if (!disposed && generationAtRequest === inspectionGeneration) { + handleProcessInspectionResult(result) + } + } catch { + consecutiveInspectionErrors += 1 + } finally { + inspectionInFlight = false + if (generationAtRequest !== inspectionGeneration) { + if (pendingTitle) { + requestInspection('pending-title') + } else { + scheduleNextPoll() + } + } else { + if (pendingTitle) { + pendingTitle.firstInspectionFinished = true + dispatchPendingTitleIfEligible() + schedulePendingTitleExpiry() + } + scheduleNextPoll() + } + } + } + }) + } + + function nextPollInterval(): number { + const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS + const backoff = + consecutiveInspectionErrors > 0 + ? Math.min(10_000, base * 2 ** consecutiveInspectionErrors) + : base + const jitter = 1 + (Math.random() * 0.2 - 0.1) + return Math.round(backoff * jitter) + } + + function scheduleNextPoll(): void { + if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) { + return + } + const ptyId = options.getPtyId() + if (!ptyId) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + requestInspection('cadence') + }, nextPollInterval()) + } + + function recordTitleWorking(): boolean { + if ( + lastCompletionSource === 'hook' && + Date.now() - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS + ) { + return false + } + workingStatusObserved = true + requiresFreshWorking = false + currentTurn += 1 + dropPendingTitle() + return true + } + + function observeTitleWorking(): void { + recordTitleWorking() + } + + function observeTitle(title: string): void { + const status = detectAgentStatusFromTitle(title) + const isInconclusiveNativeDroidTitle = titleIsInconclusiveNativeDroidTitle(title) + if (titleHasExplicitAgentIdentity(title) && !isInconclusiveNativeDroidTitle) { + establishAgentEvidence() + } + + if (status === 'working') { + if (!recordTitleWorking()) { + return + } + } else if (lastTitleStatus === 'working') { + if (isInconclusiveNativeDroidTitle) { + lastTitleStatus = status + return + } + if (agentIdentityEstablished && hasAgentRunEvidence) { + dispatchCompletion('title', title) + } else { + holdTitleCompletionPending(title) + } + } + lastTitleStatus = status + } + + function observeClassifiedTitleCompletion(title: string): void { + if (titleHasExplicitAgentIdentity(title)) { + establishAgentEvidence() + } + if (agentIdentityEstablished && hasAgentRunEvidence) { + dispatchCompletion('title', title) + } else { + holdTitleCompletionPending(title) + } + } + + function observeHookStatus(payload: ParsedAgentStatusPayload): void { + if (isRecognizedAgentType(payload.agentType)) { + establishAgentEvidence() + } + if (payload.state === 'working') { + workingStatusObserved = true + requiresFreshWorking = false + currentTurn += 1 + dropPendingTitle() + return + } + if (isCompletionHookState(payload.state)) { + if (isRecognizedAgentType(payload.agentType)) { + establishAgentEvidence() + } + if ( + !workingStatusObserved && + lastCompletionSource === 'hook' && + lastCompletedTurn === currentTurn && + Date.now() - lastCompletionAt >= COMPLETION_REPLAY_GUARD_MS + ) { + // Why: some hook producers only emit terminal states. Treat later + // done-only hook completions as new turns without letting title/process + // backstops duplicate the same completion. + currentTurn += 1 + } + dispatchCompletion('hook', payload.agentType ?? options.paneKey) + } + } + + function startProcessTracking(): void { + scheduleNextPoll() + } + + function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void { + dropPendingTitle() + agentIdentityEstablished = false + hasAgentRunEvidence = false + workingStatusObserved = false + lastTitleStatus = null + lastCompletionToken = null + lastCompletionAt = 0 + lastCompletedTurn = null + lastCompletionSource = null + lastForegroundAgent = null + requiresFreshWorking = options.requireFreshWorking ?? false + inspectionGeneration += 1 + } + + function dispose(): void { + disposed = true + clearPollTimer() + dropPendingTitle() + } + + return { + observeTitle, + observeClassifiedTitleCompletion, + observeTitleWorking, + observeHookStatus, + startProcessTracking, + resetCompletionState, + dispose + } +} diff --git a/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts new file mode 100644 index 00000000000..797407b69a3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-process-inspection-queue.ts @@ -0,0 +1,80 @@ +export type InspectionPriority = 'cadence' | 'pending-title' + +type InspectionTask = { + priority: InspectionPriority + run: () => Promise +} + +const MAX_CONCURRENT_INSPECTIONS = 4 +const MAX_INSPECTION_STARTS_PER_SECOND = 8 + +let activeInspections = 0 +let inspectionPumpTimer: ReturnType | null = null +const inspectionStarts: number[] = [] +const inspectionQueue: InspectionTask[] = [] + +function canStartInspection(now: number): boolean { + if (inspectionStarts.length > 0 && now < inspectionStarts[0]!) { + inspectionStarts.length = 0 + } + while (inspectionStarts.length > 0 && now - inspectionStarts[0]! >= 1_000) { + inspectionStarts.shift() + } + return ( + activeInspections < MAX_CONCURRENT_INSPECTIONS && + inspectionStarts.length < MAX_INSPECTION_STARTS_PER_SECOND + ) +} + +function scheduleInspectionPump(delayMs = 0): void { + if (inspectionPumpTimer !== null) { + return + } + inspectionPumpTimer = setTimeout(() => { + inspectionPumpTimer = null + pumpInspectionQueue() + }, delayMs) +} + +function pumpInspectionQueue(): void { + const now = Date.now() + if (!canStartInspection(now)) { + scheduleInspectionPump(100) + return + } + + const priorityIndex = inspectionQueue.findIndex((task) => task.priority === 'pending-title') + const next = + priorityIndex >= 0 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift() + if (!next) { + return + } + + activeInspections += 1 + inspectionStarts.push(now) + void next.run().finally(() => { + activeInspections = Math.max(0, activeInspections - 1) + if (inspectionQueue.length > 0) { + scheduleInspectionPump() + } + }) + + if (inspectionQueue.length > 0) { + scheduleInspectionPump() + } +} + +export function enqueueAgentProcessInspection(task: InspectionTask): void { + inspectionQueue.push(task) + pumpInspectionQueue() +} + +export function resetAgentProcessInspectionQueueForTests(): void { + if (inspectionPumpTimer !== null) { + clearTimeout(inspectionPumpTimer) + inspectionPumpTimer = null + } + activeInspections = 0 + inspectionStarts.length = 0 + inspectionQueue.length = 0 +} 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 7b7b209db72..060f6ea8252 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -63,6 +63,7 @@ type StoreState = { consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType agentStatusByPaneKey: Record + setAgentStatus: ReturnType removeAgentStatus: ReturnType dropAgentStatus: ReturnType } @@ -308,6 +309,15 @@ describe('connectPanePty', () => { consumePendingColdRestore: vi.fn(() => null), consumePendingSnapshot: vi.fn(() => null), agentStatusByPaneKey: {}, + setAgentStatus: vi.fn((paneKey: string, payload: Record) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + }), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn() } as StoreState @@ -319,6 +329,8 @@ describe('connectPanePty', () => { }, pty: { signal: vi.fn(), + getForegroundProcess: vi.fn().mockResolvedValue(null), + hasChildProcesses: vi.fn().mockResolvedValue(false), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -1333,6 +1345,480 @@ describe('connectPanePty', () => { ) }) + it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('does not replay disabled generic title completions after notifications are re-enabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const inspection = createDeferred() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockReturnValue(inspection.promise) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + inspection.resolve('codex') + await flushAsyncTicks() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears title completion state when notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Claude working', 'Claude working') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + titleHandler('Claude done', 'Claude done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!idleHandler) { + throw new Error('Expected onAgentBecameIdle to be registered') + } + + idleHandler('* Codex done') + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('restores a suppressed terminal bell when disabling pending agent completion', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!bellHandler || !idleHandler) { + throw new Error('Expected bell and idle handlers to be registered') + } + + bellHandler() + idleHandler('* Codex done') + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith({ source: 'terminal-bell' }) + + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ source: 'terminal-bell' }) + }) + + it('requires fresh working evidence after notifications are disabled', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: false + } + } + notifyStoreSubscribers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('requires fresh working evidence when notifications start disabled then re-enable', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + enabled: true, + agentTaskComplete: false, + terminalBell: true, + suppressWhenFocused: false, + customSoundPath: null + } + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!workingHandler || !idleHandler) { + throw new Error('Expected working and idle handlers to be registered') + } + + workingHandler() + mockStoreState.settings = { + ...mockStoreState.settings, + notifications: { + ...mockStoreState.settings.notifications, + agentTaskComplete: true + } + } + notifyStoreSubscribers() + idleHandler('* Codex done') + vi.advanceTimersByTime(1_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-codex') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('codex') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(1000) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: 'experimental-agent-observability', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + }) + + it('does not dispatch generic spinner completions when process inspection finds no agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-shell') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const api = ( + globalThis as unknown as { + window: { api: { pty: { getForegroundProcess: ReturnType } } } + } + ).window.api + api.pty.getForegroundProcess.mockResolvedValue('zsh') + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability') + titleHandler('experimental-agent-observability', 'experimental-agent-observability') + await flushAsyncTicks() + + vi.advanceTimersByTime(16_000) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('dispatches agent-task-complete from recognized hook completion events', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage: string + }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Done.' + }) + vi.advanceTimersByTime(250) + + expect(deps.dispatchNotification).toHaveBeenCalledWith({ + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + }) + it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c0a0a2c724a..8a89b9b9dc9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -18,6 +18,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './lay import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { discardTerminalOutput, flushTerminalOutput, @@ -29,6 +30,7 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { e2eConfig } from '@/lib/e2e-config' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -167,6 +169,9 @@ export function connectPanePty( let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null + let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null + let agentTaskCompleteNotificationGeneration = 0 + let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false // Why: passphrase-gate waits register a teardown here so dispose() can @@ -202,7 +207,25 @@ export function connectPanePty( }) commandLifecycle.attachXtermConsumer(pane.terminal) + const agentCompletionCoordinator = createAgentCompletionCoordinator({ + paneKey: cacheKey, + getPtyId: () => transport.getPtyId(), + getSettings: () => useAppStore.getState().settings, + inspectProcess: inspectRuntimeTerminalProcess, + dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title), + isLive: () => { + if (disposed) { + return false + } + if (transport.getPtyId()) { + return true + } + return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 + } + }) + const onExit = (ptyId: string): void => { + agentCompletionCoordinator.dispose() deps.syncPanePtyLayoutBinding(pane.id, null) deps.clearRuntimePaneTitle(deps.tabId, pane.id) deps.clearTabPtyId(deps.tabId, ptyId) @@ -245,6 +268,9 @@ export function connectPanePty( const onTitleChange = (title: string, rawTitle: string): void => { manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) deps.setRuntimePaneTitle(deps.tabId, pane.id, title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeTitle(rawTitle) + } // Why: only the focused pane should drive the tab title — otherwise two // agents in split panes cause rapid title flickering as each emits OSC // sequences. Only the active split's title propagates to the tab. When @@ -278,6 +304,7 @@ export function connectPanePty( // Spawn completion is when a pane gains a concrete PTY ID. The initial // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() + agentCompletionCoordinator.startProcessTracking() } // ─── Attention signal: BEL ──────────────────────────────────────────── // @@ -357,12 +384,43 @@ export function connectPanePty( } } + const syncAgentTaskCompleteNotificationEnabled = (): boolean => { + const enabled = isAgentTaskCompleteNotificationEnabled() + if (!enabled && wasAgentTaskCompleteNotificationEnabled) { + // Why: disabling notifications is an event-time boundary. Drop pending + // timers and coordinator state so completions observed while off cannot + // replay if the user turns the setting back on. + agentTaskCompleteNotificationGeneration += 1 + clearPendingAgentTaskCompleteNotification() + agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true }) + if (pendingTerminalBellNotification) { + scheduleTerminalBellNotification() + } + } else if (enabled && !wasAgentTaskCompleteNotificationEnabled) { + // Why: a pane may have observed work while agent-complete was disabled. + // Re-enabling should not let the next idle event notify for that old task. + agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true }) + } + wasAgentTaskCompleteNotificationEnabled = enabled + return enabled + } + const scheduleAgentTaskCompleteNotification = (title: string): void => { + if (!syncAgentTaskCompleteNotificationEnabled()) { + return + } clearPendingAgentTaskCompleteNotification() let graceElapsed = false + const generationAtSchedule = agentTaskCompleteNotificationGeneration const dispatch = (): void => { clearPendingAgentTaskCompleteNotification() + if ( + generationAtSchedule !== agentTaskCompleteNotificationGeneration || + !syncAgentTaskCompleteNotificationEnabled() + ) { + return + } pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() if (disposed) { @@ -398,6 +456,9 @@ export function connectPanePty( AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS ) } + agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe(() => { + syncAgentTaskCompleteNotificationEnabled() + }) // ─── Agent task-complete: OS notification, not tab attention ────────── // @@ -429,18 +490,14 @@ export function connectPanePty( if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) { deps.setCacheTimerStartedAt(cacheKey, Date.now()) } - // Why: this is the sole producer of 'agent-task-complete' in the renderer; - // removing it (as #944 did) leaves the user-facing Settings toggle with no - // events to fire. Dispatch is gated per-source in main; the main-process - // dedupe also collapses concurrent BEL + task-complete for the same - // worktree into a single notification. - // Why: title idle can beat the final hook status update by one event-loop - // turn; delay slightly so the notification can snapshot the richer status. - if (isAgentTaskCompleteNotificationEnabled()) { - scheduleAgentTaskCompleteNotification(title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeClassifiedTitleCompletion(title) } } const onAgentBecameWorking = (): void => { + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeTitleWorking() + } // Why: a new API call refreshes the prompt-cache TTL, so clear any running // countdown. The timer will restart when the agent becomes idle again. deps.setCacheTimerStartedAt(cacheKey, null) @@ -524,6 +581,9 @@ export function connectPanePty( const currentState = useAppStore.getState() const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] currentState.setAgentStatus(cacheKey, payload, title) + if (syncAgentTaskCompleteNotificationEnabled()) { + agentCompletionCoordinator.observeHookStatus(payload) + } } } const transport = runtimeEnvironmentId @@ -883,6 +943,7 @@ export function connectPanePty( pane.container.dataset.ptyId = ptyId deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) + agentCompletionCoordinator.startProcessTracking() // Why: mobile terminal streaming needs the exact screen state from // xterm.js. The shared helper installs both the SerializeAddon-backed @@ -1356,6 +1417,7 @@ export function connectPanePty( }) deps.syncPanePtyLayoutBinding(pane.id, attachPtyId) deps.updateTabPtyId(deps.tabId, attachPtyId) + agentCompletionCoordinator.startProcessTracking() } catch (err) { reportError(err instanceof Error ? err.message : String(err)) deps.clearTabPtyId(deps.tabId, attachPtyId) @@ -1403,6 +1465,9 @@ export function connectPanePty( onError: reportError } }) + // Why: attach sets the transport's PTY id; starting process + // tracking before this point no-ops because getPtyId() is empty. + agentCompletionCoordinator.startProcessTracking() }) .catch((err) => { reportError(err instanceof Error ? err.message : String(err)) @@ -1433,6 +1498,10 @@ export function connectPanePty( pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() discardTerminalOutput(pane.terminal) + if (agentTaskCompleteSettingsUnsubscribe !== null) { + agentTaskCompleteSettingsUnsubscribe() + agentTaskCompleteSettingsUnsubscribe = null + } if (connectFrame !== null) { // Why: StrictMode and split-group remounts can dispose a pane binding // before its deferred PTY attach/spawn work runs. Cancel that queued @@ -1449,6 +1518,7 @@ export function connectPanePty( pendingGeometryReportRaf = null } commandLifecycle.dispose() + agentCompletionCoordinator.dispose() } } } diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts new file mode 100644 index 00000000000..82006f71a84 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -0,0 +1,28 @@ +import { + detectAgentStatusFromTitle, + isGeminiTerminalTitle, + isPiTerminalTitle +} from '../../../../shared/agent-detection' + +const TITLE_AGENT_TOKEN_RE = + /(? 1, + terminalTitle: event.terminalTitle, + isActiveWorktree: state.activeWorktreeId === worktreeId, + ...agentSnapshot + }) + .then((result) => { + if (result.delivered) { + void playDesktopNotificationSound(customSoundPath) + } + }) + .catch((err) => { + console.warn('Failed to dispatch notification:', err) + }) +} + export function useNotificationDispatch( worktreeId: string ): (event: TerminalNotificationEvent) => void { return useCallback( - (event: TerminalNotificationEvent) => { - const state = useAppStore.getState() - - // Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously - // before killing PTYs asynchronously. Any notification arriving after - // that point is stale — e.g. a staleTitleTimer that fires 3 s after - // shutdown, or an agent tracker transition from accumulated closure - // state. Checking for live PTYs at dispatch time catches ALL phantom - // notification sources regardless of which timer or callback produced - // them, rather than trying to cancel each one individually. - if (!hasLivePtyForWorktree(state, worktreeId)) { - return - } - - // Why: prefer worktree.repoId over string-parsing the worktreeId. The - // `${repoId}::${path}` format is an implementation detail of id - // construction; coupling the notification dispatcher to it would silently - // drop the repo label if that format ever changes. The worktree object - // itself is the source of truth for its owning repo. - const worktree = getWorktreeMapFromState(state).get(worktreeId) - const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null - const customSoundPath = state.settings?.notifications?.customSoundPath ?? null - const agentStatus = - event.source === 'agent-task-complete' && event.paneKey - ? state.agentStatusByPaneKey[event.paneKey] - : undefined - // Why: pane keys are reused across turns. A rich OS notification must not - // expose the previous turn's prompt if the current turn has no fresh hook snapshot yet. - const hasFreshAgentStatus = - agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS - const agentSnapshot = hasFreshAgentStatus - ? { - agentType: agentStatus.agentType, - agentState: agentStatus.state, - agentPrompt: agentStatus.prompt, - agentToolName: agentStatus.toolName, - agentToolInput: agentStatus.toolInput, - agentLastAssistantMessage: agentStatus.lastAssistantMessage, - agentInterrupted: agentStatus.interrupted - } - : {} - - void window.api.notifications - .dispatch({ - source: event.source, - worktreeId, - repoLabel: repo?.displayName, - worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId, - hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1, - terminalTitle: event.terminalTitle, - isActiveWorktree: state.activeWorktreeId === worktreeId, - ...agentSnapshot - }) - .then((result) => { - if (result.delivered) { - void playDesktopNotificationSound(customSoundPath) - } - }) - .catch((err) => { - console.warn('Failed to dispatch notification:', err) - }) - }, + (event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event), [worktreeId] ) } diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts new file mode 100644 index 00000000000..3d517ad9083 --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' + +const dispatchTerminalNotification = vi.fn() + +type MockStoreState = { + settings: { + notifications: { + enabled: boolean + agentTaskComplete: boolean + } + } + ptyIdsByTabId: Record +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatusPayload { + return { + state, + prompt: 'implement notifications', + agentType: 'codex', + lastAssistantMessage: state === 'done' ? 'Done.' : undefined + } +} + +describe('agent hook completion notifications', () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + + beforeEach(() => { + vi.resetModules() + dispatchTerminalNotification.mockClear() + mockStoreState = { + settings: { + notifications: { + enabled: true, + agentTaskComplete: true + } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + } + } + }) + + it('requires fresh working after notifications start disabled and later re-enable', async () => { + mockStoreState.settings.notifications.agentTaskComplete = false + const { + observeAgentHookCompletionForNotification, + syncAgentHookCompletionNotificationSettings + } = await import('./agent-hook-completion-notifications') + + mockStoreState.settings.notifications.agentTaskComplete = true + syncAgentHookCompletionNotificationSettings() + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('done') + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts new file mode 100644 index 00000000000..dd080d8b44a --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -0,0 +1,123 @@ +import { useAppStore } from '@/store' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { createAgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator' +import type { AgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator-types' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' + +type CoordinatorEntry = { + worktreeId: string + coordinator: AgentCompletionCoordinator +} + +const coordinatorsByPaneKey = new Map() +const paneKeysRequiringFreshWorking = new Set() +let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() +let requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled + +function isAgentTaskCompleteNotificationEnabled(): boolean { + const notifications = useAppStore.getState().settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function syncAgentHookCompletionNotificationSettings(): boolean { + const enabled = isAgentTaskCompleteNotificationEnabled() + if (!enabled || (!wasAgentTaskCompleteNotificationEnabled && enabled)) { + requireFreshWorkingForNewCoordinators = true + for (const [paneKey, entry] of coordinatorsByPaneKey) { + paneKeysRequiringFreshWorking.add(paneKey) + entry.coordinator.resetCompletionState({ requireFreshWorking: true }) + } + } + wasAgentTaskCompleteNotificationEnabled = enabled + return enabled +} + +function getPtyIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return useAppStore.getState().ptyIdsByTabId?.[parsed.tabId]?.[0] ?? null +} + +function paneHasLivePty(paneKey: string): boolean { + return getPtyIdForPaneKey(paneKey) !== null +} + +function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator { + return createAgentCompletionCoordinator({ + paneKey, + getPtyId: () => getPtyIdForPaneKey(paneKey), + getSettings: () => useAppStore.getState().settings, + inspectProcess: async (): Promise => ({ + foregroundProcess: null, + hasChildProcesses: false + }), + dispatchCompletion: (title) => { + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey + }) + }, + isLive: () => paneHasLivePty(paneKey) + }) +} + +export function observeAgentHookCompletionForNotification({ + paneKey, + worktreeId, + payload +}: { + paneKey: string + worktreeId: string + payload: ParsedAgentStatusPayload +}): void { + if (!paneHasLivePty(paneKey)) { + coordinatorsByPaneKey.get(paneKey)?.coordinator.dispose() + coordinatorsByPaneKey.delete(paneKey) + paneKeysRequiringFreshWorking.delete(paneKey) + return + } + + if (!syncAgentHookCompletionNotificationSettings()) { + paneKeysRequiringFreshWorking.add(paneKey) + coordinatorsByPaneKey + .get(paneKey) + ?.coordinator.resetCompletionState({ requireFreshWorking: true }) + return + } + + let entry = coordinatorsByPaneKey.get(paneKey) + if (!entry || entry.worktreeId !== worktreeId) { + entry?.coordinator.dispose() + entry = { + worktreeId, + coordinator: createCoordinator(paneKey, worktreeId) + } + coordinatorsByPaneKey.set(paneKey, entry) + if (requireFreshWorkingForNewCoordinators) { + paneKeysRequiringFreshWorking.add(paneKey) + } + } + if (paneKeysRequiringFreshWorking.has(paneKey)) { + entry.coordinator.resetCompletionState({ requireFreshWorking: true }) + } + + entry.coordinator.observeHookStatus(payload) + if (payload.state === 'working') { + paneKeysRequiringFreshWorking.delete(paneKey) + } +} + +export function resetAgentHookCompletionNotificationCoordinators(): void { + for (const entry of coordinatorsByPaneKey.values()) { + entry.coordinator.dispose() + } + coordinatorsByPaneKey.clear() + paneKeysRequiringFreshWorking.clear() + wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() + requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index c91b5a764ab..2e5604b0ae8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -67,6 +67,11 @@ import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import { + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators, + syncAgentHookCompletionNotificationSettings +} from './agent-hook-completion-notifications' export { resolveZoomTarget } from './resolve-zoom-target' @@ -1835,6 +1840,17 @@ export function useIpcEvents(): void { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt }) + const statusWorktreeId = data.worktreeId ?? owningWorktreeId + if (options?.replay !== true && statusWorktreeId) { + // Why: local Codex/Claude hooks arrive through this main-process IPC + // path, not the PTY OSC fallback, so task-complete notifications must + // observe accepted hook state here as well. + observeAgentHookCompletionForNotification({ + paneKey: data.paneKey, + worktreeId: statusWorktreeId, + payload + }) + } } let snapshotRequestedForReadyWindow = false @@ -1927,7 +1943,12 @@ export function useIpcEvents(): void { // can be safely ignored instead of buffered against partially hydrated // renderer state. requestAgentStatusSnapshotIfReady() - unsubs.push(useAppStore.subscribe(() => requestAgentStatusSnapshotIfReady())) + unsubs.push( + useAppStore.subscribe(() => { + requestAgentStatusSnapshotIfReady() + syncAgentHookCompletionNotificationSettings() + }) + ) let mobileStateHydrated = isRuntimeEnvironmentActive() type PendingMobileStateEvent = @@ -2036,7 +2057,10 @@ export function useIpcEvents(): void { }) } - return () => unsubs.forEach((fn) => fn()) + return () => { + unsubs.forEach((fn) => fn()) + resetAgentHookCompletionNotificationCoordinators() + } }, []) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7dbad8869ef..bc3df1a2b5a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -312,6 +312,7 @@ export type UISlice = { | 'input' | 'tasks' | 'terminal' + | 'notifications' | 'computer-use' | 'developer-permissions' | 'shortcuts' diff --git a/src/shared/agent-hook-endpoint-file.test.ts b/src/shared/agent-hook-endpoint-file.test.ts new file mode 100644 index 00000000000..dfa928290ea --- /dev/null +++ b/src/shared/agent-hook-endpoint-file.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { isAgentHookEndpointFileName, parseAgentHookEndpointFile } from './agent-hook-endpoint-file' + +describe('agent hook endpoint files', () => { + it('recognizes POSIX and Windows endpoint file names', () => { + expect(isAgentHookEndpointFileName('endpoint.env')).toBe(true) + expect(isAgentHookEndpointFileName('endpoint.cmd')).toBe(true) + expect(isAgentHookEndpointFileName('endpoint.ps1')).toBe(false) + }) + + it('parses POSIX endpoint.env contents', () => { + expect( + parseAgentHookEndpointFile( + [ + 'ORCA_AGENT_HOOK_PORT=12345', + 'ORCA_AGENT_HOOK_TOKEN=token-123', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1' + ].join('\n') + ) + ).toEqual({ + port: '12345', + token: 'token-123', + env: 'production', + version: '1' + }) + }) + + it('parses Windows endpoint.cmd contents', () => { + expect( + parseAgentHookEndpointFile( + [ + 'set ORCA_AGENT_HOOK_PORT=54321', + 'set ORCA_AGENT_HOOK_TOKEN=token-abc', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1' + ].join('\r\n') + ) + ).toEqual({ + port: '54321', + token: 'token-abc', + env: 'development', + version: '1' + }) + }) + + it('preserves equals signs in endpoint values', () => { + expect( + parseAgentHookEndpointFile( + [ + 'ORCA_AGENT_HOOK_PORT=12345', + 'ORCA_AGENT_HOOK_TOKEN=token=with=equals', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1' + ].join('\n') + ).token + ).toBe('token=with=equals') + }) + + it('throws when required endpoint fields are missing', () => { + expect(() => parseAgentHookEndpointFile('ORCA_AGENT_HOOK_PORT=12345')).toThrow( + 'Agent hook endpoint file is missing required fields' + ) + }) +}) diff --git a/src/shared/agent-hook-endpoint-file.ts b/src/shared/agent-hook-endpoint-file.ts new file mode 100644 index 00000000000..6f9c1c9a717 --- /dev/null +++ b/src/shared/agent-hook-endpoint-file.ts @@ -0,0 +1,42 @@ +export const AGENT_HOOK_ENDPOINT_FILE_NAMES = ['endpoint.env', 'endpoint.cmd'] as const + +export type AgentHookEndpointFileName = (typeof AGENT_HOOK_ENDPOINT_FILE_NAMES)[number] + +export type AgentHookEndpoint = { + port: string + token: string + env: string + version: string +} + +export function isAgentHookEndpointFileName(name: string): name is AgentHookEndpointFileName { + return AGENT_HOOK_ENDPOINT_FILE_NAMES.some((fileName) => fileName === name) +} + +export function parseAgentHookEndpointFile(contents: string): AgentHookEndpoint { + const values = Object.fromEntries( + contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const normalizedLine = line.replace(/^set\s+/i, '') + const [key, ...rest] = normalizedLine.split('=') + return [key, rest.join('=')] + }) + ) + if ( + !values.ORCA_AGENT_HOOK_PORT || + !values.ORCA_AGENT_HOOK_TOKEN || + !values.ORCA_AGENT_HOOK_ENV || + !values.ORCA_AGENT_HOOK_VERSION + ) { + throw new Error('Agent hook endpoint file is missing required fields') + } + return { + port: values.ORCA_AGENT_HOOK_PORT, + token: values.ORCA_AGENT_HOOK_TOKEN, + env: values.ORCA_AGENT_HOOK_ENV, + version: values.ORCA_AGENT_HOOK_VERSION + } +} diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts new file mode 100644 index 00000000000..5b9fb5dda5c --- /dev/null +++ b/src/shared/agent-process-recognition.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { isRecognizedAgentType, recognizeAgentProcess } from './agent-process-recognition' + +describe('agent process recognition', () => { + it('recognizes packaged Codex foreground process names', () => { + expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({ + agent: 'codex', + processName: 'codex-aarch64-ap' + }) + expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true) + }) +}) diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts new file mode 100644 index 00000000000..a423b327c2f --- /dev/null +++ b/src/shared/agent-process-recognition.ts @@ -0,0 +1,77 @@ +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { AgentType } from './agent-status-types' +import type { TuiAgent } from './types' + +export type RecognizedAgentProcess = { + agent: TuiAgent + processName: string +} + +const EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i + +function normalizeProcessName(processName: string | null | undefined): string { + if (!processName) { + return '' + } + const unquoted = processName.trim().replace(/^["']|["']$/g, '') + const basename = unquoted.split(/[\\/]/).pop() ?? unquoted + return basename.toLowerCase().replace(EXTENSION_RE, '') +} + +function firstCommandToken(command: string): string { + return command.trim().split(/\s+/)[0] ?? '' +} + +const PROCESS_TO_AGENT = new Map() +const AGENT_TYPE_IDS = new Set() + +for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [ + TuiAgent, + (typeof TUI_AGENT_CONFIG)[TuiAgent] +][]) { + AGENT_TYPE_IDS.add(agent) + for (const candidate of [ + config.expectedProcess, + config.detectCmd, + firstCommandToken(config.launchCmd) + ]) { + const normalized = normalizeProcessName(candidate) + if (normalized) { + PROCESS_TO_AGENT.set(normalized, agent) + } + } +} + +function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { + const exact = PROCESS_TO_AGENT.get(normalized) + if (exact) { + return exact + } + // Why: node-pty can report Codex's packaged platform binary + // (for example codex-aarch64-ap) instead of the launch command. + if (normalized.startsWith('codex-')) { + return PROCESS_TO_AGENT.get('codex') + } + return undefined +} + +export function recognizeAgentProcess( + processName: string | null | undefined +): RecognizedAgentProcess | null { + const normalized = normalizeProcessName(processName) + const agent = agentForNormalizedProcess(normalized) + if (!agent) { + return null + } + return { agent, processName: normalized } +} + +export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean { + if (typeof agentType !== 'string') { + return false + } + return ( + AGENT_TYPE_IDS.has(agentType as TuiAgent) || + agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined + ) +} diff --git a/tests/e2e/droid-notification.spec.ts b/tests/e2e/droid-notification.spec.ts index 711c8b47c14..78374552753 100644 --- a/tests/e2e/droid-notification.spec.ts +++ b/tests/e2e/droid-notification.spec.ts @@ -8,10 +8,16 @@ import { waitForTerminalOutput } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { emitCodexHookStatus, readHookEndpoint } from './helpers/agent-hook-endpoint' type NotificationDispatch = { source?: string terminalTitle?: string + paneKey?: string + isActiveWorktree?: boolean + agentType?: string + agentPrompt?: string + agentLastAssistantMessage?: string } async function emitOscTitle(page: Page, ptyId: string, title: string) { @@ -46,7 +52,205 @@ async function getNotificationDispatches( }) } +async function createAndSwitchToOtherWorktree(page: Page): Promise { + return page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const activeWorktreeId = state.activeWorktreeId + if (!activeWorktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === activeWorktreeId) + if (!activeWorktree) { + throw new Error(`Active worktree ${activeWorktreeId} not found`) + } + const result = await state.createWorktree( + activeWorktree.repoId, + `codex-hook-notify-${Date.now()}` + ) + await state.fetchWorktrees(activeWorktree.repoId) + state.setActiveWorktree(result.worktree.id) + return result.worktree.id + }) +} + +async function getAgentStatuses(page: Page): Promise< + { + paneKey: string + state: string + agentType?: string + prompt?: string + lastAssistantMessage?: string + }[] +> { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.values(store.getState().agentStatusByPaneKey ?? {}).map((entry) => ({ + paneKey: entry.paneKey, + state: entry.state, + agentType: entry.agentType, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage + })) + }) +} + +async function getActivePaneDescriptor( + page: Page +): Promise<{ paneKey: string; worktreeId: string }> { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const tabId = state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId + if (!tabId) { + throw new Error('No active tab') + } + const manager = window.__paneManagers?.get(tabId) + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + const leafId = activePane ? manager?.getLeafIdMap?.().get(activePane.id) : null + if (!leafId) { + throw new Error('No active pane leaf id') + } + return { paneKey: `${tabId}:${leafId}`, worktreeId } + }) +} + test.describe('Droid notifications', () => { + test('Codex hook completion dispatches while its worktree is inactive', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installMainProcessNotificationDispatchSpy(electronApp) + const endpoint = await readHookEndpoint(electronApp) + + const { paneKey, worktreeId } = await getActivePaneDescriptor(orcaPage) + const prompt = `codex-hook-notify-${Date.now()}` + await emitCodexHookStatus(endpoint, { + paneKey, + worktreeId, + state: 'working', + prompt + }) + await expect + .poll( + async () => + (await getAgentStatuses(orcaPage)).some( + (status) => + status.agentType === 'codex' && status.state === 'working' && status.prompt === prompt + ), + { + timeout: 10_000, + message: 'Codex UserPromptSubmit hook did not reach renderer agent status' + } + ) + .toBe(true) + + await createAndSwitchToOtherWorktree(orcaPage) + + const finalMessage = `Codex hook completed ${Date.now()}` + await emitCodexHookStatus(endpoint, { + paneKey, + worktreeId, + state: 'done', + prompt, + lastAssistantMessage: finalMessage + }) + await expect + .poll( + async () => + (await getAgentStatuses(orcaPage)).some( + (status) => + status.agentType === 'codex' && + status.state === 'done' && + status.prompt === prompt && + status.lastAssistantMessage === finalMessage + ), + { + timeout: 10_000, + message: 'Codex Stop hook did not reach renderer agent status' + } + ) + .toBe(true) + + await expect + .poll( + async () => { + const dispatches = await getNotificationDispatches(electronApp) + return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete') + }, + { + timeout: 10_000, + message: 'Codex hook Stop did not dispatch task-complete while worktree was inactive' + } + ) + .toEqual([ + expect.objectContaining({ + source: 'agent-task-complete', + terminalTitle: 'codex', + isActiveWorktree: false, + agentType: 'codex', + agentPrompt: prompt, + agentLastAssistantMessage: finalMessage + }) + ]) + }) + + test('recognized agent title completion dispatches one task-complete notification', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + // Why: contextBridge freezes window.api, so notification invokes must be + // observed in Electron's main process rather than monkey-patched renderer-side. + await installMainProcessNotificationDispatchSpy(electronApp) + await installRendererTitleLog(orcaPage) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const marker = `__CODEX_NOTIFY_READY_${Date.now()}__` + await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`) + await waitForTerminalOutput(orcaPage, marker) + + await emitOscTitle(orcaPage, ptyId, 'Codex working') + await emitOscTitle(orcaPage, ptyId, 'Codex done') + + await expect + .poll( + async () => { + const dispatches = await getNotificationDispatches(electronApp) + return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete') + }, + { + timeout: 10_000, + message: 'Codex working->done title transition did not dispatch task-complete' + } + ) + .toEqual([ + expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'Codex done' }) + ]) + }) + test('Factory Droid needs-input native title does not dispatch a task-complete notification', async ({ orcaPage, electronApp diff --git a/tests/e2e/helpers/agent-hook-endpoint.ts b/tests/e2e/helpers/agent-hook-endpoint.ts new file mode 100644 index 00000000000..eefa42daeeb --- /dev/null +++ b/tests/e2e/helpers/agent-hook-endpoint.ts @@ -0,0 +1,79 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { existsSync, readdirSync, readFileSync } from 'fs' +import path from 'path' +import { + isAgentHookEndpointFileName, + parseAgentHookEndpointFile, + type AgentHookEndpoint +} from '../../../src/shared/agent-hook-endpoint-file' + +function findEndpointEnvFile(root: string): string | null { + if (!existsSync(root)) { + return null + } + const entries = readdirSync(root, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(root, entry.name) + if (entry.isFile() && isAgentHookEndpointFileName(entry.name)) { + return fullPath + } + if (entry.isDirectory()) { + const nested = findEndpointEnvFile(fullPath) + if (nested) { + return nested + } + } + } + return null +} + +export async function readHookEndpoint(app: ElectronApplication): Promise { + const userDataPath = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')) + const hookRoot = path.join(userDataPath, 'agent-hooks') + const endpointPath = findEndpointEnvFile(hookRoot) + if (!endpointPath) { + throw new Error(`Agent hook endpoint file not found under ${hookRoot}`) + } + return parseAgentHookEndpointFile(readFileSync(endpointPath, 'utf8')) +} + +export async function emitCodexHookStatus( + endpoint: AgentHookEndpoint, + status: { + paneKey: string + worktreeId: string + state: 'working' | 'done' + prompt?: string + lastAssistantMessage?: string + } +): Promise { + const [tabId] = status.paneKey.split(':') + const payload = + status.state === 'working' + ? { + hook_event_name: 'UserPromptSubmit', + prompt: status.prompt + } + : { + hook_event_name: 'Stop', + last_assistant_message: status.lastAssistantMessage + } + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey: status.paneKey, + tabId, + worktreeId: status.worktreeId, + env: endpoint.env, + version: endpoint.version, + payload + }) + }) + if (response.status !== 204) { + throw new Error(`Codex hook POST returned ${response.status}`) + } +} diff --git a/tests/e2e/notification-settings.spec.ts b/tests/e2e/notification-settings.spec.ts new file mode 100644 index 00000000000..ae33f26d7ae --- /dev/null +++ b/tests/e2e/notification-settings.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import type { GlobalSettings } from '../../src/shared/types' + +async function getSettings( + page: Parameters[0] +): Promise { + return page.evaluate(() => window.api.settings.get()) +} + +async function openNotificationSettings( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'notifications', repoId: null }) + state.openSettingsPage() + }) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const featureTipDialog = page.getByRole('dialog', { name: 'Voice Dictation is here' }) + if (await featureTipDialog.isVisible().catch(() => false)) { + await page.getByRole('button', { name: 'Maybe Later' }).click() + } + await expect( + page + .locator('[data-settings-section="notifications"]') + .getByRole('heading', { name: 'Notifications', exact: true }) + ).toBeInViewport({ timeout: 10_000 }) +} + +test.describe('Notification settings', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('can be toggled from settings and disables child controls', async ({ orcaPage }) => { + await openNotificationSettings(orcaPage) + + const notificationsSection = orcaPage.locator('[data-settings-section="notifications"]') + const enableNotificationsSwitch = notificationsSection.getByRole('switch', { + name: 'Enable Notifications' + }) + const agentTaskCompleteSwitch = notificationsSection.getByRole('switch', { + name: 'Agent Task Complete' + }) + const terminalBellSwitch = notificationsSection.getByRole('switch', { name: 'Terminal Bell' }) + const suppressWhileFocusedSwitch = notificationsSection.getByRole('switch', { + name: 'Suppress While Focused' + }) + const sendTestButton = notificationsSection.getByRole('button', { + name: 'Send Test Notification' + }) + + await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'true') + await expect(agentTaskCompleteSwitch).toBeEnabled() + await expect(terminalBellSwitch).toBeEnabled() + await expect(suppressWhileFocusedSwitch).toBeEnabled() + await expect(sendTestButton).toBeEnabled() + + await agentTaskCompleteSwitch.click() + await expect(agentTaskCompleteSwitch).toHaveAttribute('aria-checked', 'false') + await expect + .poll(async () => (await getSettings(orcaPage)).notifications.agentTaskComplete, { + timeout: 5_000, + message: 'agent task-complete notification setting did not persist after disabling' + }) + .toBe(false) + + await enableNotificationsSwitch.click() + await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'false') + await expect(agentTaskCompleteSwitch).toBeDisabled() + await expect(terminalBellSwitch).toBeDisabled() + await expect(suppressWhileFocusedSwitch).toBeDisabled() + await expect(sendTestButton).toBeDisabled() + await expect + .poll(async () => (await getSettings(orcaPage)).notifications.enabled, { + timeout: 5_000, + message: 'master notification setting did not persist after disabling' + }) + .toBe(false) + + await enableNotificationsSwitch.click() + await agentTaskCompleteSwitch.click() + await expect + .poll(async () => { + const settings = await getSettings(orcaPage) + return { + enabled: settings.notifications.enabled, + agentTaskComplete: settings.notifications.agentTaskComplete + } + }) + .toEqual({ enabled: true, agentTaskComplete: true }) + }) +}) From 140ceb7fdaed864e29c4c1fc441d4a0724ee2e81 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 19 May 2026 03:41:49 -0400 Subject: [PATCH 19/20] Add automation CLI commands (#2315) Co-authored-by: Orca --- skills/orca-cli/SKILL.md | 28 +- src/cli/args.ts | 43 +- src/cli/dispatch.ts | 2 + src/cli/format.ts | 64 +++ src/cli/handlers/automations.ts | 338 +++++++++++++ src/cli/help.ts | 23 +- src/cli/index.test.ts | 444 ++++++++++++++++++ src/cli/index.ts | 3 +- src/cli/specs/automations.ts | 91 ++++ src/cli/specs/index.ts | 2 + src/main/automations/service.test.ts | 35 ++ src/main/automations/service.ts | 16 +- src/main/index.ts | 1 + .../runtime/orca-runtime-automations.test.ts | 141 ++++++ src/main/runtime/orca-runtime.ts | 200 ++++++++ .../runtime/rpc/methods/automations.test.ts | 120 +++++ src/main/runtime/rpc/methods/automations.ts | 116 +++++ src/main/runtime/rpc/methods/index.ts | 2 + 18 files changed, 1657 insertions(+), 12 deletions(-) create mode 100644 src/cli/handlers/automations.ts create mode 100644 src/cli/specs/automations.ts create mode 100644 src/main/runtime/orca-runtime-automations.test.ts create mode 100644 src/main/runtime/rpc/methods/automations.test.ts create mode 100644 src/main/runtime/rpc/methods/automations.ts diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index f44cdae4b89..bd640c577ce 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -2,8 +2,9 @@ name: orca-cli description: >- Use the `orca` CLI to drive a running Orca editor — manage Orca worktrees; - create, read, and run shell commands in Orca-managed terminals; and automate - Orca's built-in browser (snapshot/click/fill/screenshot/tabs). Use this + create and manage scheduled automations; create, read, and run shell commands + in Orca-managed terminals; and automate Orca's built-in browser + (snapshot/click/fill/screenshot/tabs). Use this instead of raw `git worktree`, ad hoc shell PTYs, or Playwright whenever the task touches Orca state. Coding agents inside an Orca worktree should also use it to keep the worktree comment fresh at meaningful checkpoints. Boundary with @@ -27,6 +28,7 @@ Use `orca` for: - updating the current worktree comment with meaningful progress checkpoints - reading Orca-managed terminals and sending input to non-agent terminals - stopping or waiting on Orca-managed terminals +- creating and managing scheduled Orca automations - accessing repos known to Orca Do not use `orca` when plain shell tools are simpler and Orca state does not matter. @@ -36,6 +38,7 @@ Examples: - updating the current worktree comment after a significant checkpoint, such as reproducing a bug, validating a fix, or handing off for review - finding the Claude Code terminal for a worktree and reading its status - checking which Orca worktrees have live terminal activity +- creating a scheduled automation that runs a prompt against a known repo or worktree ## Preconditions @@ -98,6 +101,7 @@ orca terminal list --json 4. Act through Orca: - `worktree create/set/rm` +- `automations list/show/create/edit/remove/run/runs` - `terminal read/send/wait/stop` 5. When the agent reaches a significant checkpoint in the current worktree, update the Orca worktree comment so the UI reflects the latest work-in-progress: @@ -142,6 +146,25 @@ Worktree selectors supported in focused v1: - `issue:` - `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd` +### Automations + +```bash +orca automations list --json +orca automations show --json +orca automations create --name "Daily review" --trigger daily --time 09:00 --prompt "Review open changes" --provider codex --repo id: --json +orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo path:/abs/repo --disabled --json +orca automations edit --name "Weekday review" --trigger weekdays --time 09:30 --json +orca automations run --json +orca automations runs --id --json +orca automations remove --json +``` + +Automation schedules accept `hourly`, `daily`, `weekdays`, `weekly`, a 5-field cron expression, or an RRULE string. Use `--time ` with `daily`, `weekdays`, or `weekly`; use `--day <0-6>` only with `weekly`, where Sunday is `0`. + +Use `--repo ` for a new worktree per run, or `--workspace ` / `--workspace-mode existing` when the automation should run in an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive. + +Why: automations are persisted through the running Orca runtime, so use the CLI instead of editing automation storage files directly. Prefer `--disabled` when creating an automation during tests or setup so it cannot run before the user reviews it. + ### Terminal Use selectors to discover terminals, then use the returned handle for repeated live interaction. @@ -175,6 +198,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea ## Agent Guidance - If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`. +- If the user says to create/manage a scheduled Orca automation, use `orca automations ...`, not direct persistence edits. - Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action. - Prefer `--json` for all machine-driven use. - Use `worktree ps` as the first summary view when many worktrees may exist. diff --git a/src/cli/args.ts b/src/cli/args.ts index 59472dcd258..5b993306321 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -3,6 +3,7 @@ import { RuntimeClientError } from './runtime-client' export type ParsedArgs = { commandPath: string[] flags: Map + positionalFlagConflicts?: string[] } export type CommandSpec = { @@ -10,6 +11,7 @@ export type CommandSpec = { summary: string usage: string allowedFlags: string[] + positionalArgs?: string[] examples?: string[] notes?: string[] } @@ -62,7 +64,9 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['open', 'status'].includes(commandPath[0])) { return false } - if (['repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])) { + if ( + ['automations', 'repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0]) + ) { return false } return ![ @@ -79,6 +83,7 @@ export function isCommandGroup(commandPath: string[]): boolean { return ( (commandPath.length === 1 && [ + 'automations', 'repo', 'worktree', 'terminal', @@ -101,6 +106,33 @@ export function isCommandGroup(commandPath: string[]): boolean { ) } +export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs { + for (const spec of specs) { + const positionalArgs = spec.positionalArgs ?? [] + if (positionalArgs.length === 0) { + continue + } + if (parsed.commandPath.length !== spec.path.length + positionalArgs.length) { + continue + } + if (!matches(parsed.commandPath.slice(0, spec.path.length), spec.path)) { + continue + } + const flags = new Map(parsed.flags) + const values = parsed.commandPath.slice(spec.path.length) + // Why: validation runs inside main's error-reporting path, so normalization + // records ambiguity instead of throwing before CLI errors can be formatted. + const positionalFlagConflicts = positionalArgs.filter((name) => flags.has(name)) + positionalArgs.forEach((name, index) => { + if (!flags.has(name)) { + flags.set(name, values[index]) + } + }) + return { commandPath: spec.path, flags, positionalFlagConflicts } + } + return parsed +} + export function findCommandSpec( specs: CommandSpec[], commandPath: string[] @@ -117,6 +149,15 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs ) } + if (parsed.positionalFlagConflicts && parsed.positionalFlagConflicts.length > 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Pass ${parsed.positionalFlagConflicts + .map((flag) => `--${flag}`) + .join(', ')} either positionally or as a flag, not both.` + ) + } + for (const flag of parsed.flags.keys()) { if ( !spec.allowedFlags.includes(flag) && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a9adbadf543..97698764522 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,6 +1,7 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { CORE_HANDLERS } from './handlers/core' +import { AUTOMATION_HANDLERS } from './handlers/automations' import { REPO_HANDLERS } from './handlers/repo' import { WORKTREE_HANDLERS } from './handlers/worktree' import { TERMINAL_HANDLERS } from './handlers/terminal' @@ -29,6 +30,7 @@ function buildHandlers(): Map { const table = new Map() const groups = [ CORE_HANDLERS, + AUTOMATION_HANDLERS, REPO_HANDLERS, WORKTREE_HANDLERS, TERMINAL_HANDLERS, diff --git a/src/cli/format.ts b/src/cli/format.ts index 9518dfde602..e40a2290665 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -33,6 +33,8 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { Automation, AutomationRun } from '../shared/automations-types' +import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -282,6 +284,68 @@ export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): .join('\n') } +export function formatAutomationList(result: { automations: Automation[] }): string { + if (result.automations.length === 0) { + return 'No automations found.' + } + return result.automations + .map((automation) => { + const status = automation.enabled ? 'enabled' : 'disabled' + return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}` + }) + .join('\n\n') +} + +export function formatAutomationShow(result: { automation: Automation }): string { + const automation = result.automation + return [ + `id: ${automation.id}`, + `name: ${automation.name}`, + `provider: ${automation.agentId}`, + `enabled: ${automation.enabled}`, + `schedule: ${formatAutomationSchedule(automation.rrule)}`, + `rrule: ${automation.rrule}`, + `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, + `projectId: ${automation.projectId}`, + `workspaceMode: ${automation.workspaceMode}`, + `workspaceId: ${automation.workspaceId ?? 'null'}`, + `baseBranch: ${automation.baseBranch ?? 'null'}`, + `target: ${automation.executionTargetType}:${automation.executionTargetId}`, + `prompt: ${automation.prompt}` + ].join('\n') +} + +export function formatAutomationRemoved(result: { removed: boolean; id: string }): string { + return result.removed + ? `Removed automation ${result.id}.` + : `Automation ${result.id} not removed.` +} + +export function formatAutomationRun(result: { run: AutomationRun }): string { + return [ + `id: ${result.run.id}`, + `automationId: ${result.run.automationId}`, + `title: ${result.run.title}`, + `status: ${result.run.status}`, + `trigger: ${result.run.trigger}`, + `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, + `workspaceId: ${result.run.workspaceId ?? 'null'}`, + `error: ${result.run.error ?? 'null'}` + ].join('\n') +} + +export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { + if (result.runs.length === 0) { + return 'No automation runs found.' + } + return result.runs + .map( + (run) => + `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.error ? `\nerror: ${run.error}` : ''}` + ) + .join('\n\n') +} + export function formatSnapshot(result: BrowserSnapshotResult): string { const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n` return header + result.snapshot diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts new file mode 100644 index 00000000000..f5c0fcfc0e0 --- /dev/null +++ b/src/cli/handlers/automations.ts @@ -0,0 +1,338 @@ +/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */ +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationSchedulePreset, + AutomationUpdateInput +} from '../../shared/automations-types' +import type { TuiAgent } from '../../shared/types' +import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' +import { isTuiAgent } from '../../shared/tui-agent-config' +import type { CommandHandler } from '../dispatch' +import { + formatAutomationList, + formatAutomationRemoved, + formatAutomationRun, + formatAutomationRuns, + formatAutomationShow, + printResult +} from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' + +type AutomationCreateParams = Omit & { + repo?: string + timezone?: string + workspace?: string +} + +type AutomationUpdateParams = AutomationUpdateInput & { + repo?: string + workspace?: string +} + +const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) +const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const + +function getScheduleModifierFlag(flags: Map): string | undefined { + return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) +} + +function validateScheduleModifierApplicability( + flags: Map, + raw: string +): void { + const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + if (!isPreset) { + if (flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time can only be used with preset automation triggers' + ) + } + if (flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } + return + } + if (raw === 'hourly' && flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' + ) + } + if (raw !== 'weekly' && flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } +} + +function getOptionalDayFlag(flags: Map): number | undefined { + const raw = flags.get('day') + if (raw === undefined) { + return undefined + } + const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN + if (!Number.isInteger(day) || day < 0 || day > 6) { + throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') + } + return day +} + +function getProviderFlag(flags: Map): TuiAgent { + const provider = getRequiredStringFlag(flags, 'provider') + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getOptionalProviderFlag(flags: Map): TuiAgent | undefined { + const provider = getOptionalStringFlag(flags, 'provider') + if (provider === undefined) { + return undefined + } + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getTimeFlag(flags: Map): { hour: number; minute: number } { + const value = flags.get('time') + if (value === undefined) { + return { hour: 9, minute: 0 } + } + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + return parseTimeFlag(value) +} + +function parseTimeFlag(value: string): { hour: number; minute: number } { + const match = /^(\d{1,2}):(\d{2})$/.exec(value) + if (!match) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + const hour = Number(match[1]) + const minute = Number(match[2]) + if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') + } + return { hour, minute } +} + +function getScheduleFlag( + flags: Map, + required: boolean +): { rrule: string; dtstart: number } | undefined { + const trigger = getOptionalStringFlag(flags, 'trigger') + const schedule = getOptionalStringFlag(flags, 'schedule') + if (trigger && schedule) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --trigger or --schedule, not both.' + ) + } + const raw = trigger ?? schedule + if (!raw) { + const modifier = getScheduleModifierFlag(flags) + if (modifier) { + throw new RuntimeClientError( + 'invalid_argument', + `--${modifier} requires --trigger or --schedule` + ) + } + if (required) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + return undefined + } + if (raw === 'manual') { + throw new RuntimeClientError( + 'invalid_argument', + 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' + ) + } + validateScheduleModifierApplicability(flags, raw) + const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) + const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 + const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + ? buildAutomationRrule({ + preset: raw as Exclude, + hour, + minute, + dayOfWeek + }) + : raw + if (!isValidAutomationSchedule(rrule)) { + throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) + } + return { rrule, dtstart: Date.now() } +} + +function getEnabledFlag(flags: Map): boolean | undefined { + const enabledFlag = flags.get('enabled') + const disabledFlag = flags.get('disabled') + if (typeof enabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') + } + if (typeof disabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') + } + const enabled = enabledFlag === true + const disabled = disabledFlag === true + if (enabled && disabled) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --enabled or --disabled, not both.' + ) + } + if (enabled) { + return true + } + if (disabled) { + return false + } + return undefined +} + +function getWorkspaceModeFlag( + flags: Map +): 'existing' | 'new_per_run' | undefined { + const value = getOptionalStringFlag(flags, 'workspace-mode') + if (value === undefined) { + return undefined + } + if (value === 'existing') { + return 'existing' + } + if (value === 'new-per-run' || value === 'new_per_run') { + return 'new_per_run' + } + throw new RuntimeClientError( + 'invalid_argument', + '--workspace-mode must be existing or new-per-run' + ) +} + +async function resolveDefaultTarget( + flags: Map, + cwd: string, + client: Parameters[0]['client'] +): Promise<{ repo?: string; workspace?: string }> { + const repo = getOptionalStringFlag(flags, 'repo') + if (repo && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') + } + const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) + if (repo || workspace) { + return { repo, workspace } + } + if (client.isRemote) { + return {} + } + try { + return { workspace: await resolveCurrentWorktreeSelector(cwd, client) } + } catch { + return {} + } +} + +async function getExplicitTarget( + flags: Map, + cwd: string, + client: Parameters[0]['client'] +): Promise<{ repo?: string; workspace?: string }> { + const repo = getOptionalStringFlag(flags, 'repo') + if (repo && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') + } + const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) + return { repo, workspace } +} + +export const AUTOMATION_HANDLERS: Record = { + 'automations list': async ({ client, json }) => { + const result = await client.call<{ automations: Automation[] }>('automation.list') + printResult(result, json, formatAutomationList) + }, + 'automations show': async ({ flags, client, json }) => { + const result = await client.call<{ automation: Automation }>('automation.show', { + id: getRequiredStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationShow) + }, + 'automations create': async ({ flags, client, cwd, json }) => { + const schedule = getScheduleFlag(flags, true) + if (!schedule) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + const target = await resolveDefaultTarget(flags, cwd, client) + const workspaceMode = + getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run') + const result = await client.call<{ automation: Automation }>('automation.create', { + name: getRequiredStringFlag(flags, 'name'), + prompt: getRequiredStringFlag(flags, 'prompt'), + agentId: getProviderFlag(flags), + repo: target.repo, + workspace: target.workspace, + workspaceMode, + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + timezone: getOptionalStringFlag(flags, 'timezone'), + enabled: getEnabledFlag(flags), + missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), + ...schedule + } satisfies AutomationCreateParams) + printResult(result, json, formatAutomationShow) + }, + 'automations edit': async ({ flags, client, cwd, json }) => { + const target = await getExplicitTarget(flags, cwd, client) + const schedule = getScheduleFlag(flags, false) + const result = await client.call<{ automation: Automation }>('automation.update', { + id: getRequiredStringFlag(flags, 'id'), + updates: { + name: getOptionalStringFlag(flags, 'name'), + prompt: getOptionalStringFlag(flags, 'prompt'), + agentId: getOptionalProviderFlag(flags), + repo: target.repo, + workspace: target.workspace, + workspaceMode: getWorkspaceModeFlag(flags), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + timezone: getOptionalStringFlag(flags, 'timezone'), + enabled: getEnabledFlag(flags), + missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), + ...schedule + } satisfies AutomationUpdateParams + }) + printResult(result, json, formatAutomationShow) + }, + 'automations remove': async ({ flags, client, json }) => { + const id = getRequiredStringFlag(flags, 'id') + const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { id }) + printResult(result, json, formatAutomationRemoved) + }, + 'automations run': async ({ flags, client, json }) => { + const result = await client.call<{ run: AutomationRun }>('automation.runNow', { + id: getRequiredStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationRun) + }, + 'automations runs': async ({ flags, client, json }) => { + const result = await client.call<{ runs: AutomationRun[] }>('automation.runs', { + automationId: getOptionalStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationRuns) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 9c54fed812b..f612ded8a4d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -17,6 +17,15 @@ Environments: environment show Show one saved remote Orca runtime environment rm Remove a saved remote Orca runtime +Automations: + automations list List scheduled Orca automations + automations show Show one Orca automation + automations create Create a scheduled Orca automation + automations edit Edit an Orca automation + automations remove Remove an Orca automation and its run history + automations run Run an Orca automation now + automations runs List automation run history + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -345,7 +354,7 @@ export function formatFlagHelp(flag: string): string { key: '--key Key or combo to press, e.g. Escape or CmdOrCtrl+L', limit: '--limit Maximum number of rows to return', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', - name: '--name Name for the new worktree', + name: '--name Name for the new worktree or automation', 'no-parent': '--no-parent Force no parent lineage', 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', @@ -367,6 +376,18 @@ export function formatFlagHelp(flag: string): string { 'to-y': '--to-y Destination window-local y coordinate', worktree: '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', + workspace: '--workspace Existing worktree selector for automation runs', + prompt: '--prompt Automation prompt to pass to the agent', + provider: '--provider Agent id such as codex, claude, or gemini', + trigger: '--trigger Automation schedule preset, cron, or RRULE', + schedule: '--schedule Alias for --trigger', + time: '--time Time used with daily/weekdays/weekly presets', + day: '--day <0-6> Day used with weekly preset, Sunday=0', + timezone: '--timezone IANA timezone for the automation', + enabled: '--enabled Enable the automation', + disabled: '--disabled Disable the automation', + 'workspace-mode': '--workspace-mode existing or new-per-run', + 'missed-run-grace-minutes': '--missed-run-grace-minutes Missed-run grace window', 'value-stdin': '--value-stdin Read set-value payload from stdin', 'window-id': '--window-id Target a window id from list-windows', 'window-index': '--window-index Target a window index from list-windows', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index addffeb3b93..64b01d64158 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1439,4 +1439,448 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) }) + + it('creates an automation for the enclosing worktree by default', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_automation_create', { + automation: { + id: 'auto-1', + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'repo-1::/tmp/repo/feature', + baseBranch: null, + timezone: 'America/Toronto', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: 'existing', + baseBranch: undefined, + timezone: undefined, + enabled: undefined, + missedRunGraceMinutes: undefined, + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: expect.any(Number) + }) + }) + + it('rejects invalid automation --day values before calling the runtime', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Weekly review', + '--trigger', + 'weekly', + '--day', + '7', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--day must be an integer from 0 to 6' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { + name: 'day on daily preset', + args: ['--trigger', 'daily', '--day', '2'], + message: '--day can only be used with the weekly automation preset' + }, + { + name: 'time on custom cron', + args: ['--trigger', '0 9 * * *', '--time', '10:30'], + message: '--time can only be used with preset automation triggers' + }, + { + name: 'time on hourly preset', + args: ['--trigger', 'hourly', '--time', '10:30'], + message: '--time cannot be used with the hourly automation preset' + } + ])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + ...args, + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { + name: 'create', + args: [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--time', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ] + }, + { + name: 'edit', + args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json'] + } + ])('rejects bare automation --time on $name', async ({ args }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(args, '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--time must use HH:MM format' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation edit schedule modifiers without a schedule flag', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--day requires --trigger or --schedule' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation create with both repo and workspace targets', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--repo', + 'id:repo-1', + '--workspace', + 'id:repo-1::/tmp/repo/feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --repo or --workspace, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation edit with both repo and workspace targets', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'edit', + 'auto-1', + '--repo', + 'id:repo-1', + '--workspace', + 'id:repo-1::/tmp/repo/feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --repo or --workspace, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { flag: 'enabled', value: 'false', message: '--enabled does not take a value' }, + { flag: 'disabled', value: 'false', message: '--disabled does not take a value' } + ])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--repo', + 'id:repo-1', + `--${flag}`, + value, + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('resolves explicit automation create workspace active from cwd', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--workspace', + 'active', + '--json' + ], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: 'existing', + baseBranch: undefined, + timezone: undefined, + enabled: undefined, + missedRunGraceMinutes: undefined, + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: expect.any(Number) + }) + }) + + it('resolves explicit automation edit workspace current from cwd', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['automations', 'edit', 'auto-1', '--workspace', 'current', '--enabled', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { + id: 'auto-1', + updates: { + name: undefined, + prompt: undefined, + agentId: undefined, + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: undefined, + baseBranch: undefined, + timezone: undefined, + enabled: true, + missedRunGraceMinutes: undefined + } + }) + }) + + it('passes positional automation ids to edit, remove, run, and show', async () => { + queueFixtures( + callMock, + okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }), + okFixture('req_remove', { removed: true, id: 'auto-1' }), + okFixture('req_run', { + run: { + id: 'run-1', + automationId: 'auto-1', + title: 'Paused run 1', + status: 'pending', + trigger: 'manual', + scheduledFor: 1, + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + outputSnapshot: null, + usage: null, + error: null, + startedAt: null, + dispatchedAt: null, + createdAt: 1 + } + }), + okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo') + await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo') + await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo') + await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', { + id: 'auto-1', + updates: { + name: undefined, + prompt: undefined, + agentId: undefined, + repo: undefined, + workspace: undefined, + workspaceMode: undefined, + baseBranch: undefined, + timezone: undefined, + enabled: false, + missedRunGraceMinutes: undefined + } + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' }) + }) + + it('rejects ambiguous positional and flag automation ids before dispatch', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: 'Pass --id either positionally or as a flag, not both.' + } + }) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index d80789c4772..247b73cea61 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { findCommandSpec, isCommandGroup, + normalizeCommandPositionals, parseArgs, resolveHelpPath, validateCommandAndFlags @@ -20,7 +21,7 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { - const parsed = parseArgs(argv) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv)) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { printHelp(COMMAND_SPECS, helpPath) diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts new file mode 100644 index 00000000000..5f71d137ff5 --- /dev/null +++ b/src/cli/specs/automations.ts @@ -0,0 +1,91 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch'] +const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone'] +const AUTOMATION_STATE_FLAGS = ['enabled', 'disabled', 'missed-run-grace-minutes'] + +export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['automations', 'list'], + summary: 'List scheduled Orca automations', + usage: 'orca automations list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca automations list', 'orca automations list --json'] + }, + { + path: ['automations', 'show'], + summary: 'Show one Orca automation', + usage: 'orca automations show [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations show 2f9e...', 'orca automations show --id 2f9e... --json'] + }, + { + path: ['automations', 'create'], + summary: 'Create a scheduled Orca automation', + usage: + 'orca automations create --name --trigger --prompt --provider [--repo |--workspace ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'name', + 'prompt', + 'provider', + ...AUTOMATION_TARGET_FLAGS, + ...AUTOMATION_SCHEDULE_FLAGS, + ...AUTOMATION_STATE_FLAGS + ], + notes: [ + 'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.', + 'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.', + 'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.' + ], + examples: [ + 'orca automations create --name "Daily review" --trigger daily --prompt "Review open changes" --provider codex', + 'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo' + ] + }, + { + path: ['automations', 'edit'], + summary: 'Edit an Orca automation', + usage: 'orca automations edit [--name ] [--trigger ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'id', + 'name', + 'prompt', + 'provider', + ...AUTOMATION_TARGET_FLAGS, + ...AUTOMATION_SCHEDULE_FLAGS, + ...AUTOMATION_STATE_FLAGS + ], + positionalArgs: ['id'], + examples: [ + 'orca automations edit 2f9e... --disabled', + 'orca automations edit --id 2f9e... --trigger "30 * * * *" --json' + ] + }, + { + path: ['automations', 'remove'], + summary: 'Remove an Orca automation and its run history', + usage: 'orca automations remove [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations remove 2f9e...', 'orca automations remove --id 2f9e... --json'] + }, + { + path: ['automations', 'run'], + summary: 'Run an Orca automation now', + usage: 'orca automations run [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations run 2f9e...', 'orca automations run --id 2f9e... --json'] + }, + { + path: ['automations', 'runs'], + summary: 'List automation run history', + usage: 'orca automations runs [--id ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + examples: ['orca automations runs', 'orca automations runs --id 2f9e... --json'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 48c05a44142..4695f831adb 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -1,6 +1,7 @@ import type { CommandSpec } from '../args' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' +import { AUTOMATION_COMMAND_SPECS } from './automations' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' @@ -8,6 +9,7 @@ import { ENVIRONMENT_COMMAND_SPECS } from './environment' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...AUTOMATION_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 3ea54afd649..58651ceda6e 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -89,6 +89,41 @@ describe('AutomationService', () => { ) }) + it('returns the persisted status for manual runs after dispatch is requested', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Manual check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatching') + expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching') + expect(send).toHaveBeenCalledWith( + 'automations:dispatchRequested', + expect.objectContaining({ + run: expect.objectContaining({ id: run.id, status: 'dispatching' }) + }) + ) + }) + it('attaches provider usage when a completed run can be attributed', async () => { vi.setSystemTime(new Date('2026-05-13T10:00:00')) const store = await createStore() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 678b1ca62a1..655dc8d5d85 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -69,8 +69,7 @@ export class AutomationService { throw new Error('Automation not found.') } const run = this.store.createAutomationRun(automation, Date.now(), 'manual') - await this.requestDispatch(automation, run) - return run + return await this.requestDispatch(automation, run) } async markDispatchResult(result: AutomationDispatchResult): Promise { @@ -214,25 +213,28 @@ export class AutomationService { this.store.advanceAutomationNextRun(automation.id, now) } - private async requestDispatch(automation: Automation, run: AutomationRun): Promise { + private async requestDispatch( + automation: Automation, + run: AutomationRun + ): Promise { const webContents = this.webContents if (!webContents || webContents.isDestroyed() || !this.rendererReady) { - this.store.updateAutomationRun({ + return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, error: 'No Orca window was available to launch the automation.' }) - return } - this.store.updateAutomationRun({ + const updated = this.store.updateAutomationRun({ runId: run.id, status: 'dispatching', workspaceId: automation.workspaceId, error: null }) - const payload: AutomationDispatchRequest = { automation, run } + const payload: AutomationDispatchRequest = { automation, run: updated } webContents.send('automations:dispatchRequested', payload) + return updated } } diff --git a/src/main/index.ts b/src/main/index.ts index fa07a5c9fe1..149008ea1e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -789,6 +789,7 @@ app.whenReady().then(async () => { }) runtime = runtimeService automations = new AutomationService(store, { claudeUsage, codexUsage }) + runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ prepareForCodexLaunch: () => diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts new file mode 100644 index 00000000000..59435f49e9d --- /dev/null +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/types' + +const repo: Repo = { + id: 'repo-1', + path: '/tmp/orca', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +function makeStore(existingAutomations: Automation[] = []) { + return { + getRepos: vi.fn(() => [repo]), + createAutomation: vi.fn((input) => ({ + id: 'auto-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + createdAt: 1, + updatedAt: 1, + ...input + })), + listAutomations: vi.fn(() => existingAutomations), + listAutomationRuns: vi.fn(() => []), + updateAutomation: vi.fn((id, updates) => ({ ...existingAutomations[0], id, ...updates })), + deleteAutomation: vi.fn(), + getSettings: vi.fn(() => ({ + workspaceDir: '/tmp', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: '', + branchPrefixCustom: '' + })), + getAllWorktreeMeta: vi.fn(() => new Map()), + getWorktreeMeta: vi.fn(), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: vi.fn() + } +} + +const existingAutomation = { + id: 'auto-1', + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: 'main', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 +} satisfies Automation + +describe('OrcaRuntimeService automation methods', () => { + it('creates repo-scoped automations through the shared store', async () => { + const store = makeStore() + const runtime = new OrcaRuntimeService(store as never) + + const automation = await runtime.createAutomation({ + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1', + workspaceMode: 'new_per_run', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + + expect(store.createAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + workspaceId: null + }) + ) + expect(automation.id).toBe('auto-1') + }) + + it('updates and deletes existing automations through the shared store', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + const updated = await runtime.updateAutomation('auto-1', { enabled: false }) + const removed = runtime.deleteAutomation('auto-1') + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }) + expect(updated).toMatchObject({ + prompt: existingAutomation.prompt, + baseBranch: existingAutomation.baseBranch, + workspaceMode: existingAutomation.workspaceMode, + enabled: false + }) + expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(removed).toEqual({ removed: true, id: 'auto-1' }) + }) + + it('preserves explicit nullable fields in sparse automation updates', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + await runtime.updateAutomation('auto-1', { baseBranch: null }) + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + }) + + it('rejects repo-only updates for existing-workspace automations', async () => { + const existing = { + ...existingAutomation, + workspaceMode: 'existing', + workspaceId: 'repo-1::/tmp/orca-worktree', + baseBranch: null + } satisfies Automation + const store = makeStore([existing]) + const runtime = new OrcaRuntimeService(store as never) + + await expect(runtime.updateAutomation('auto-1', { repo: 'repo-2' })).rejects.toThrow( + 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' + ) + expect(store.updateAutomation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 974f296014a..ec76763d939 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,13 @@ import { basename, isAbsolute, join } from 'path' import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput, + AutomationWorkspaceMode +} from '../../shared/automations-types' import type { BaseRefSearchResult, CreateWorktreeResult, @@ -91,6 +98,7 @@ import type { BrowserTabInfo, BrowserScreencastResult } from '../../shared/runtime-types' +import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -323,6 +331,11 @@ type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] + listAutomations?: Store['listAutomations'] + listAutomationRuns?: Store['listAutomationRuns'] + createAutomation?: Store['createAutomation'] + updateAutomation?: Store['updateAutomation'] + deleteAutomation?: Store['deleteAutomation'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -339,6 +352,31 @@ type RuntimeStore = { updateSettings?: (updates: Partial) => unknown } +export type RuntimeAutomationCreateInput = Omit< + AutomationCreateInput, + 'projectId' | 'workspaceId' | 'workspaceMode' | 'timezone' +> & { + repo?: string + workspace?: string + workspaceMode?: AutomationWorkspaceMode + timezone?: string +} + +export type RuntimeAutomationUpdateInput = Omit< + AutomationUpdateInput, + 'projectId' | 'workspaceId' +> & { + repo?: string + workspace?: string +} + +function hasRuntimeAutomationUpdateValue( + updates: RuntimeAutomationUpdateInput, + key: K +): boolean { + return Object.hasOwn(updates, key) && updates[key] !== undefined +} + type RuntimeLeafRecord = RuntimeSyncedLeaf & { ptyGeneration: number connected: boolean @@ -908,6 +946,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null + private automationService: AutomationService | null = null private mobileDictation: { id: string owner: string @@ -961,6 +1000,163 @@ export class OrcaRuntimeService { return this.store.getUI() } + listAutomations(): Automation[] { + if (!this.store?.listAutomations) { + throw new Error('runtime_unavailable') + } + return this.store.listAutomations() + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + if (!this.store?.listAutomationRuns) { + throw new Error('runtime_unavailable') + } + return this.store.listAutomationRuns(automationId) + } + + showAutomation(id: string): Automation { + const automation = this.listAutomations().find((entry) => entry.id === id) + if (!automation) { + throw new Error('Automation not found.') + } + return automation + } + + async createAutomation(input: RuntimeAutomationCreateInput): Promise { + if (!this.store?.createAutomation) { + throw new Error('runtime_unavailable') + } + const target = await this.resolveAutomationTarget(input) + return this.store.createAutomation({ + name: input.name, + prompt: input.prompt, + agentId: input.agentId, + projectId: target.projectId, + workspaceMode: target.workspaceMode, + workspaceId: target.workspaceId, + baseBranch: input.baseBranch, + timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + rrule: input.rrule, + dtstart: input.dtstart, + enabled: input.enabled, + missedRunGraceMinutes: input.missedRunGraceMinutes + }) + } + + async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise { + if (!this.store?.updateAutomation) { + throw new Error('runtime_unavailable') + } + const current = this.showAutomation(id) + const patch: AutomationUpdateInput = {} + if (hasRuntimeAutomationUpdateValue(updates, 'name')) { + patch.name = updates.name + } + if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { + patch.prompt = updates.prompt + } + if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { + patch.agentId = updates.agentId + } + if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { + patch.baseBranch = updates.baseBranch + } + if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) { + patch.timezone = updates.timezone + } + if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) { + patch.rrule = updates.rrule + } + if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) { + patch.dtstart = updates.dtstart + } + if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) { + patch.enabled = updates.enabled + } + if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) { + patch.missedRunGraceMinutes = updates.missedRunGraceMinutes + } + const targetChanged = + hasRuntimeAutomationUpdateValue(updates, 'repo') || + hasRuntimeAutomationUpdateValue(updates, 'workspace') || + hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') + if (targetChanged) { + const target = await this.resolveAutomationTarget(updates, current) + patch.projectId = target.projectId + patch.workspaceMode = target.workspaceMode + patch.workspaceId = target.workspaceId + } + return this.store.updateAutomation(id, patch) + } + + deleteAutomation(id: string): { removed: boolean; id: string } { + if (!this.store?.deleteAutomation) { + throw new Error('runtime_unavailable') + } + this.showAutomation(id) + this.store.deleteAutomation(id) + return { removed: true, id } + } + + async runAutomationNow(id: string): Promise { + if (!this.automationService) { + throw new Error('runtime_unavailable') + } + return await this.automationService.runNow(id) + } + + private async resolveAutomationTarget( + input: { + repo?: string + workspace?: string + workspaceMode?: AutomationWorkspaceMode + baseBranch?: string | null + }, + current?: Automation + ): Promise<{ + projectId: string + workspaceMode: AutomationWorkspaceMode + workspaceId?: string | null + }> { + const hasRepo = input.repo !== undefined + const hasWorkspace = input.workspace !== undefined + if ( + current?.workspaceMode === 'existing' && + hasRepo && + !hasWorkspace && + input.workspaceMode !== 'new_per_run' + ) { + throw new Error( + 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' + ) + } + const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null + const repo = input.repo ? await this.showRepo(input.repo) : null + const workspaceMode = + input.workspaceMode ?? + (workspace + ? 'existing' + : input.repo && !current + ? 'new_per_run' + : (current?.workspaceMode ?? 'new_per_run')) + if (workspaceMode === 'existing') { + const workspaceId = workspace?.id ?? current?.workspaceId + const projectId = workspace?.repoId ?? current?.projectId + if (repo && repo.id !== projectId) { + throw new Error('Selected workspace belongs to a different repo.') + } + if (!workspaceId || !projectId) { + throw new Error('Existing-workspace automation requires --workspace.') + } + return { projectId, workspaceMode, workspaceId } + } + const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId + if (!projectId) { + throw new Error('Automation requires --repo or --workspace.') + } + return { projectId, workspaceMode: 'new_per_run', workspaceId: null } + } + // Why: lazy initialization — the DB path depends on Electron's userData // which may not be finalized until after app.ready. Also allows unit tests // to inject an in-memory DB without touching the filesystem. @@ -977,6 +1173,10 @@ export class OrcaRuntimeService { this._orchestrationDb = db } + setAutomationService(service: AutomationService): void { + this.automationService = service + } + getRuntimeId(): string { return this.runtimeId } diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts new file mode 100644 index 00000000000..f2c8dce9995 --- /dev/null +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { AUTOMATION_METHODS } from './automations' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('automation RPC methods', () => { + it('routes automation CRUD and run operations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]), + showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }), + createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }), + updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }), + deleteAutomation: vi.fn().mockReturnValue({ removed: true, id: 'auto-1' }), + runAutomationNow: vi.fn().mockResolvedValue({ id: 'run-1', automationId: 'auto-1' }), + listAutomationRuns: vi.fn().mockReturnValue([{ id: 'run-1', automationId: 'auto-1' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await dispatcher.dispatch(makeRequest('automation.list')) + await dispatcher.dispatch(makeRequest('automation.show', { id: 'auto-1' })) + await dispatcher.dispatch( + makeRequest('automation.create', { + name: 'New review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + ) + await dispatcher.dispatch( + makeRequest('automation.update', { + id: 'auto-1', + updates: { + enabled: false, + rrule: '0 9 * * 1-5', + dtstart: 2 + } + }) + ) + await dispatcher.dispatch(makeRequest('automation.delete', { id: 'auto-1' })) + await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' })) + await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' })) + + expect(runtime.listAutomations).toHaveBeenCalled() + expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.createAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1' + }) + ) + expect(runtime.updateAutomation).toHaveBeenCalledWith( + 'auto-1', + expect.objectContaining({ enabled: false, rrule: '0 9 * * 1-5' }) + ) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') + expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1') + }) + + it('rejects unknown providers and invalid schedules', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createAutomation: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await expect( + dispatcher.dispatch( + makeRequest('automation.create', { + name: 'Bad provider', + prompt: 'Run', + agentId: 'not-real', + repo: 'repo-1', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + + await expect( + dispatcher.dispatch( + makeRequest('automation.create', { + name: 'Bad schedule', + prompt: 'Run', + agentId: 'codex', + repo: 'repo-1', + rrule: 'not a schedule', + dtstart: 1 + }) + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + }) + + it('preserves null baseBranch update values through the RPC boundary', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', baseBranch: null }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await dispatcher.dispatch( + makeRequest('automation.update', { + id: 'auto-1', + updates: { baseBranch: null } + }) + ) + + expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + }) +}) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts new file mode 100644 index 00000000000..97bd301ea91 --- /dev/null +++ b/src/main/runtime/rpc/methods/automations.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from '../schemas' + +const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() + +const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { + message: 'Invalid automation trigger' +}) + +const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +const AutomationId = z.object({ + id: requiredString('Missing automation id') +}) + +const AutomationRuns = z.object({ + automationId: OptionalString +}) + +const AutomationCreate = z.object({ + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + agentId: TuiAgent, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + agentId: TuiAgent.optional(), + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields +}) + +export const AUTOMATION_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'automation.list', + params: null, + handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() }) + }), + defineMethod({ + name: 'automation.show', + params: AutomationId, + handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) }) + }), + defineMethod({ + name: 'automation.create', + params: AutomationCreate, + handler: async (params, { runtime }) => ({ + automation: await runtime.createAutomation(params) + }) + }), + defineMethod({ + name: 'automation.update', + params: AutomationUpdate, + handler: async (params, { runtime }) => ({ + automation: await runtime.updateAutomation(params.id, params.updates) + }) + }), + defineMethod({ + name: 'automation.delete', + params: AutomationId, + handler: (params, { runtime }) => runtime.deleteAutomation(params.id) + }), + defineMethod({ + name: 'automation.runNow', + params: AutomationId, + handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) }) + }), + defineMethod({ + name: 'automation.runs', + params: AutomationRuns, + handler: (params, { runtime }) => ({ + runs: runtime.listAutomationRuns(params.automationId) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index b04c4f6ef8b..de831057f04 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,6 @@ import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' +import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' @@ -26,6 +27,7 @@ import { CLIENT_UI_METHODS } from './client-ui' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AUTOMATION_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, ...TERMINAL_METHODS, From 5c93579e4020a795ae6c05606aca01852a5adc13 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 19 May 2026 00:42:23 -0700 Subject: [PATCH 20/20] Refresh optimistic worktrees after base fetch (#2310) * Refresh optimistic worktrees after base fetch * Refresh remote-tracking bases before creating worktrees - Ensure local and SSH worktree creation fetches the exact remote base ref before `git worktree add`, so new worktrees do not start from stale refs. - Fail creation before mutating git state when the required base refresh fails or remains missing. - Replace post-create repair/reconcile behavior with read-only drift reporting and add coverage for refresh ordering, cache bypassing, and SSH failures. * Queue exact base refreshes during remote fetches * rm design doc --- src/main/ipc/worktree-remote.ts | 251 +++++++++--------- src/main/ipc/worktrees-windows.test.ts | 2 +- src/main/ipc/worktrees.test.ts | 148 +++++++++-- src/main/runtime/fetch-remote-cache.test.ts | 135 ++++++++++ src/main/runtime/orca-runtime.test.ts | 278 ++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 148 +++++++---- 6 files changed, 773 insertions(+), 189 deletions(-) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index f97b0af4229..9d2afdf96de 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -48,13 +48,6 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth' import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' -async function readCommitSha(repoPath: string, ref: string): Promise { - const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', `${ref}^{commit}`], { - cwd: repoPath - }) - return stdout.trim() -} - async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise { const target = parseGitHubOwnerRepo(remoteUrl) try { @@ -289,17 +282,50 @@ async function configureCreatedWorktreePushTargetSsh( return target } +async function resolveRemoteTrackingBaseSsh( + provider: SshGitProvider, + repoPath: string, + baseBranch: string +): Promise { + let remotes: string[] + try { + const { stdout } = await provider.exec(['remote'], repoPath) + remotes = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return null + } + + const remote = remotes + .filter((candidate) => baseBranch.startsWith(`${candidate}/`)) + .sort((a, b) => b.length - a.length)[0] + if (!remote) { + return null + } + const branch = baseBranch.slice(remote.length + 1) + if (!branch) { + return null + } + return { + remote, + branch, + ref: `refs/remotes/${remote}/${branch}`, + base: baseBranch + } +} + export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } } -// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately -// after kicking off `git fetch` and `'creating'` after that fetch resolves -// (or is determined to be cache-fresh). Renderer swaps its spinner label in -// response; fallback is the static "Creating worktree..." label if no event -// arrives (e.g. renderer races destruction of the window). +// Why: two-phase spinner. Main process fires `'fetching'` before waiting on +// pre-create fetch work and `'creating'` immediately before `git worktree add`. +// Renderer swaps its spinner label in response; fallback is the static +// "Creating worktree..." label if no event arrives. export function emitCreateWorktreeProgress( mainWindow: BrowserWindow, phase: 'fetching' | 'creating' @@ -385,12 +411,33 @@ export async function createRemoteWorktree( ) } - // Fetch latest - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - try { - await provider.exec(['fetch', remote], repo.path) - } catch { - /* best-effort */ + const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch) + if (remoteTrackingBase) { + try { + await provider.exec( + [ + 'fetch', + '--no-tags', + remoteTrackingBase.remote, + `+refs/heads/${remoteTrackingBase.branch}:${remoteTrackingBase.ref}` + ], + repo.path + ) + } catch { + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + } else { + // Why: local or otherwise non-remote-tracking bases preserve legacy + // best-effort fetch behavior. Only remote-tracking bases must fail closed, + // because creating from them after a failed refresh silently makes stale worktrees. + const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + try { + await provider.exec(['fetch', fallbackRemote], repo.path) + } catch { + /* best-effort */ + } } let preparedPushTarget: GitPushTarget | undefined @@ -524,10 +571,9 @@ export async function createLocalWorktree( ? sanitizeWorktreeDisplayName(args.displayName) : undefined - // Why (§3.3): determine the base branch (and therefore the remote we need to - // fetch) FIRST, so the fetch can overlap all pre-create work below. Neither - // of these calls depends on the suffix loop / PR probe / branch-conflict - // resolution, so they are safe to hoist. + // Why: resolve the base before branch/path selection so remote-tracking bases + // can be refreshed before `git worktree add`. Creating first and repairing + // later races setup scripts, agents, and user edits. const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) if (!baseBranch) { // Why: getDefaultBaseRef may return null when none of origin/HEAD, @@ -540,31 +586,23 @@ export async function createLocalWorktree( ) } - let optimisticBase: RemoteTrackingBase | null = null - let optimisticFetchPromise: Promise | null = null - let initialBaseStatus: CreateWorktreeResult['initialBaseStatus'] + let remoteTrackingBase: RemoteTrackingBase | null = null + let remoteTrackingRefresh: { + base: RemoteTrackingBase + hadLocalBaseRef: boolean + promise: Promise + } | null = null let legacyFetchPromise: Promise | null = null if (runtime) { - optimisticBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch) - if (optimisticBase) { - const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase) - if (hasLocalBaseRef) { - const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote) - if (!isFresh) { - optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote) - } - } else { - emitCreateWorktreeProgress(mainWindow, 'fetching') - const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote) - if (!(await runtime.hasRemoteTrackingRef(repo.path, optimisticBase))) { - if (!result.ok) { - throw new Error( - `Could not fetch base ref "${baseBranch}" from "${optimisticBase.remote}". Check your network and try again.` - ) - } - throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) - } + remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch) + if (remoteTrackingBase) { + const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase) + emitCreateWorktreeProgress(mainWindow, 'fetching') + remoteTrackingRefresh = { + base: remoteTrackingBase, + hadLocalBaseRef: hasLocalBaseRef, + promise: runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) } } else { // Why: when the base branch does not match a configured remote prefix @@ -593,6 +631,40 @@ export async function createLocalWorktree( const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir + + // Why: this validation does not depend on remote refs, so it can overlap a + // required remote-tracking base refresh. + const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup + if (primarySetupScript) { + shouldRunSetupForCreate(repo, args.setupDecision) + } + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + let sparsePresetId: string | undefined + if (args.sparseCheckout?.presetId) { + const preset = store + .getSparsePresets(repo.id) + .find((entry) => entry.id === args.sparseCheckout?.presetId) + if (preset?.repoId === repo.id) { + try { + const presetDirectories = normalizeSparseDirectories(preset.directories) + // Why: use Set-based comparison so directory order does not affect + // attribution — matches the renderer's sparseDirectoriesMatch logic. + const presetSet = new Set(presetDirectories) + const directoriesMatch = + presetDirectories.length === sparseDirectories.length && + sparseDirectories.every((entry) => presetSet.has(entry)) + sparsePresetId = directoriesMatch ? preset.id : undefined + } catch { + // Why: corrupt preset data should not block creation or falsely label the new worktree. + } + } + } + let effectiveRequestedName = requestedName let effectiveSanitizedName = sanitizedName let branchName = '' @@ -682,39 +754,18 @@ export async function createLocalWorktree( ) } - // Why: `ask` is a pre-create choice gate, not a post-create side effect. - // Resolve it before mutating git state so missing UI input cannot strand - // a real worktree on disk while the renderer reports "create failed". The - // actual run/skip decision is recomputed after the worktree exists against - // the worktree-bound setup script. - const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup - if (primarySetupScript) { - shouldRunSetupForCreate(repo, args.setupDecision) - } - const sparseDirectories = args.sparseCheckout - ? normalizeSparseDirectories(args.sparseCheckout.directories) - : [] - if (args.sparseCheckout && sparseDirectories.length === 0) { - throw new Error('Sparse checkout requires at least one repo-relative directory.') - } - let sparsePresetId: string | undefined - if (args.sparseCheckout?.presetId) { - const preset = store - .getSparsePresets(repo.id) - .find((entry) => entry.id === args.sparseCheckout?.presetId) - if (preset?.repoId === repo.id) { - try { - const presetDirectories = normalizeSparseDirectories(preset.directories) - // Why: use Set-based comparison so directory order does not affect - // attribution — matches the renderer's sparseDirectoriesMatch logic. - const presetSet = new Set(presetDirectories) - const directoriesMatch = - presetDirectories.length === sparseDirectories.length && - sparseDirectories.every((entry) => presetSet.has(entry)) - sparsePresetId = directoriesMatch ? preset.id : undefined - } catch { - // Why: corrupt preset data should not block creation or falsely label the new worktree. - } + if (remoteTrackingRefresh) { + const result = await remoteTrackingRefresh.promise + if (!result.ok) { + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.` + ) + } + if ( + !remoteTrackingRefresh.hadLocalBaseRef && + !(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base)) + ) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) } } @@ -860,53 +911,9 @@ export async function createLocalWorktree( } } - if (runtime && optimisticBase && optimisticFetchPromise) { - initialBaseStatus = { - repoId: repo.id, - worktreeId, - status: 'checking', - base: optimisticBase.base, - remote: optimisticBase.remote - } - runtime.emitWorktreeBaseStatus(initialBaseStatus) - // Why: record the reconcile token BEFORE the rev-parse await so a racing - // worktree remove during the await isn't a no-op (its - // clearOptimisticReconcileToken would then run before the token exists, - // letting the post-await record install a fresh token whose reconcile - // would re-populate base status for a worktree that no longer exists). - const token = runtime.recordOptimisticReconcileToken(worktreeId) - try { - const createdBaseSha = await readCommitSha(created.path, 'HEAD') - void runtime - .reconcileWorktreeBaseStatus({ - repoId: repo.id, - repoPath: repo.path, - worktreeId, - base: optimisticBase, - branchName, - createdBaseSha, - token, - fetchPromise: optimisticFetchPromise - }) - .catch((error) => { - console.warn(`[worktree-base-status] reconcile failed for ${worktreeId}:`, error) - }) - } catch (error) { - console.warn(`[worktree-base-status] failed to read created base for ${worktreeId}:`, error) - runtime.emitWorktreeBaseStatus({ - repoId: repo.id, - worktreeId, - status: 'unknown', - base: optimisticBase.base, - remote: optimisticBase.remote - }) - } - } - notifyWorktreesChanged(mainWindow, repo.id) return { worktree, - ...(setup ? { setup } : {}), - ...(initialBaseStatus ? { initialBaseStatus } : {}) + ...(setup ? { setup } : {}) } } diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 6bca9622e64..2cbeeb85acc 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -186,7 +186,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { const runtimeStub = { resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), - isRemoteFetchFresh: vi.fn().mockResolvedValue(false), + getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }), getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }), fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined), emitWorktreeBaseStatus: vi.fn(), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 5e53b672d3a..12d6033da65 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -176,7 +176,7 @@ describe('registerWorktreeHandlers', () => { let runtimeStub: { resolveRemoteTrackingBase: ReturnType hasRemoteTrackingRef: ReturnType - isRemoteFetchFresh: ReturnType + getOrStartRemoteTrackingBaseRefresh: ReturnType getOrStartRemoteFetch: ReturnType fetchRemoteWithCache: ReturnType emitWorktreeBaseStatus: ReturnType @@ -319,7 +319,7 @@ describe('registerWorktreeHandlers', () => { runtimeStub = { resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), - isRemoteFetchFresh: vi.fn().mockResolvedValue(false), + getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }), getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }), fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined), emitWorktreeBaseStatus: vi.fn(), @@ -761,7 +761,12 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: 'origin/main' } const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi.fn().mockResolvedValue([ { @@ -811,6 +816,50 @@ describe('registerWorktreeHandlers', () => { }) }) + it('does not create an SSH worktree when remote-tracking base refresh fails', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }), + addWorktree: vi.fn(), + listWorktrees: vi.fn() + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + }) + ).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + + expect(provider.addWorktree).not.toHaveBeenCalled() + }) + it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => { const repo = { id: 'repo-ssh', @@ -909,7 +958,7 @@ describe('registerWorktreeHandlers', () => { ) }) - it('does not await a cold fetch when the remote-tracking base exists locally', async () => { + it('awaits a cold refresh before creating from an existing remote-tracking base', async () => { const remoteBase = { remote: 'origin', branch: 'main', @@ -922,7 +971,7 @@ describe('registerWorktreeHandlers', () => { }) runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) - runtimeStub.getOrStartRemoteFetch.mockReturnValue(pendingFetch) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockReturnValue(pendingFetch) listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -939,33 +988,88 @@ describe('registerWorktreeHandlers', () => { name: 'improve-dashboard' }) as Promise - const result = await Promise.race([ - createPromise, - new Promise((resolve) => setTimeout(() => resolve('timed-out'), 0)) + const earlyResult = await Promise.race([ + createPromise.then(() => 'resolved'), + new Promise((resolve) => setTimeout(() => resolve('pending'), 0)) ]) - expect(result).not.toBe('timed-out') + expect(earlyResult).toBe('pending') + expect(addWorktreeMock).not.toHaveBeenCalled() - expect(runtimeStub.getOrStartRemoteFetch).toHaveBeenCalledWith('/workspace/repo', 'origin') + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + '/workspace/repo', + remoteBase + ) expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled() - expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({ - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/improve-dashboard', - status: 'checking', - base: 'origin/main', - remote: 'origin' - }) - expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith( + resolveFetch() + const result = await createPromise + expect(addWorktreeMock).toHaveBeenCalled() + expect(result).toEqual( expect.objectContaining({ - createdBaseSha: 'created-sha', - fetchPromise: pendingFetch + worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) }) ) + }) + + it('does not create when the pre-create remote-tracking refresh fails', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: false, + errorKind: 'git_error' + }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + ).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refreshes before create even when the remote-tracking base was recently fetched', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'created-sha', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + '/workspace/repo', + remoteBase + ) expect(result).toEqual( expect.objectContaining({ - initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' }) + worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) }) ) - resolveFetch() }) it('throws a clear error when no default base ref can be resolved', async () => { diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 4f60ffb4388..ea691434295 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -129,4 +129,139 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { base: 'foo/bar/main' }) }) + + it('refreshes a remote-tracking base with an exact no-tags refspec', async () => { + mockFetchResults([{ stdout: '', stderr: '' }]) + const runtime = new OrcaRuntimeService(null) + + await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/f', { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + { cwd: '/repo/f' } + ) + }) + + it('shares an in-flight remote-tracking base refresh without using freshness cache', async () => { + let resolveFetch!: () => void + const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveFetch = () => resolve({ stdout: '', stderr: '' }) + }) + mockFetchResults([pending, { stdout: '', stderr: '' }]) + const runtime = new OrcaRuntimeService(null) + const base = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + + const first = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base) + const second = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchCallCount()).toBe(1) + + resolveFetch() + await Promise.all([first, second]) + await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base) + + expect(fetchCallCount()).toBe(2) + }) + + it('queues a full remote fetch behind an in-flight remote-tracking base refresh', async () => { + let resolveBaseFetch!: () => void + let resolveFullFetch!: () => void + const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveBaseFetch = () => resolve({ stdout: '', stderr: '' }) + }) + const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveFullFetch = () => resolve({ stdout: '', stderr: '' }) + }) + mockFetchResults([pendingBaseFetch, pendingFullFetch]) + const runtime = new OrcaRuntimeService(null) + const base = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + + const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/h', base) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchCallCount()).toBe(1) + + const fullFetch = runtime.getOrStartRemoteFetch('/repo/h', 'origin') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchCallCount()).toBe(1) + + resolveBaseFetch() + await vi.waitFor(() => expect(fetchCallCount()).toBe(2)) + resolveFullFetch() + + await expect(Promise.all([baseRefresh, fullFetch])).resolves.toEqual([ + { ok: true }, + { ok: true } + ]) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' + ) + expect(fetchCalls).toEqual([ + [ + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + { cwd: '/repo/h' } + ], + [['fetch', 'origin'], { cwd: '/repo/h' }] + ]) + }) + + it('queues an exact base refresh behind an in-flight full remote fetch', async () => { + let resolveFullFetch!: () => void + let resolveBaseFetch!: () => void + const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveFullFetch = () => resolve({ stdout: '', stderr: '' }) + }) + const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => { + resolveBaseFetch = () => resolve({ stdout: '', stderr: '' }) + }) + mockFetchResults([pendingFullFetch, pendingBaseFetch]) + const runtime = new OrcaRuntimeService(null) + const base = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + + const fullFetch = runtime.getOrStartRemoteFetch('/repo/i', 'origin') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchCallCount()).toBe(1) + + const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/i', base) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(fetchCallCount()).toBe(1) + + resolveFullFetch() + await vi.waitFor(() => expect(fetchCallCount()).toBe(2)) + resolveBaseFetch() + + await expect(Promise.all([fullFetch, baseRefresh])).resolves.toEqual([ + { ok: true }, + { ok: true } + ]) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + ([argv]) => Array.isArray(argv) && argv[0] === 'fetch' + ) + expect(fetchCalls).toEqual([ + [['fetch', 'origin'], { cwd: '/repo/i' }], + [ + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + { cwd: '/repo/i' } + ] + ]) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2738a83fb50..0d42b34fd8d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -733,6 +733,97 @@ describe('OrcaRuntimeService', () => { }) }) + it('refreshes runtime remote-tracking bases before creating local worktrees', async () => { + const runtime = new OrcaRuntimeService(store) + const refresh = deferred<{ stdout: string; stderr: string }>() + const createdWorktree = { + path: '/tmp/workspaces/cli-fresh-base', + head: 'def', + branch: 'cli-fresh-base', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify') { + return { stdout: 'base-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + return refresh.promise + } + return { stdout: '', stderr: '' } + }) + try { + const createPromise = runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-fresh-base' + }) + + await vi.waitFor(() => { + expect(gitSpy).toHaveBeenCalledWith( + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + { cwd: TEST_REPO_PATH } + ) + }) + expect(addWorktree).not.toHaveBeenCalled() + + refresh.resolve({ stdout: '', stderr: '' }) + const result = await createPromise + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'cli-fresh-base', + 'origin/main', + false + ) + expect(result.worktree).toMatchObject({ path: createdWorktree.path }) + } finally { + gitSpy.mockRestore() + } + }) + + it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => { + const runtime = new OrcaRuntimeService(store) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify') { + return { stdout: 'base-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-refresh-fails' + }) + ).rejects.toThrow( + 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + ) + + expect(addWorktree).not.toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => { const runtime = new OrcaRuntimeService(store) vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' }) @@ -5485,6 +5576,193 @@ describe('OrcaRuntimeService', () => { expect(worktreeBaseStatus).not.toHaveBeenCalled() }) + const remoteTrackingBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + + function createReconcileRuntime(): { + runtime: OrcaRuntimeService + worktreeBaseStatus: ReturnType + } { + const worktreeBaseStatus = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreeBaseStatus, + worktreeRemoteBranchConflict: vi.fn() + } as never) + return { runtime, worktreeBaseStatus } + } + + function mockReconcileGit(options: { + postFetchSha?: string + ancestor?: boolean + baseRefMissing?: boolean + }) { + const { postFetchSha = 'new-base-sha', ancestor = true, baseRefMissing = false } = options + + return vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const command = args as string[] + const cwd = (options as { cwd?: string } | undefined)?.cwd + if ( + cwd === TEST_REPO_PATH && + command[0] === 'rev-parse' && + command[1] === '--verify' && + command[2] === `${remoteTrackingBase.ref}^{commit}` + ) { + if (baseRefMissing) { + throw new Error('missing base ref') + } + return { stdout: `${postFetchSha}\n`, stderr: '' } + } + if (cwd === TEST_REPO_PATH && command[0] === 'merge-base') { + if (!ancestor) { + throw new Error('not ancestor') + } + return { stdout: '', stderr: '' } + } + if (cwd === TEST_REPO_PATH && command[0] === 'rev-list') { + return { stdout: '3\n', stderr: '' } + } + if (cwd === TEST_REPO_PATH && command[0] === 'log') { + return { stdout: 'base commit 3\nbase commit 2\n', stderr: '' } + } + if (cwd === TEST_REPO_PATH && command[0] === 'config') { + throw new Error('config missing') + } + if ( + cwd === TEST_REPO_PATH && + command[0] === 'rev-parse' && + command[1] === '--verify' && + command[2] === 'refs/remotes/origin/feature^{commit}' + ) { + throw new Error('no publish branch conflict') + } + throw new Error(`unexpected git command: ${command.join(' ')}`) + }) + } + + async function reconcileWithToken(runtime: OrcaRuntimeService, token: string): Promise { + await runtime.reconcileWorktreeBaseStatus({ + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreeId: TEST_WORKTREE_ID, + base: remoteTrackingBase, + branchName: 'feature', + createdBaseSha: 'created-base-sha', + token, + fetchPromise: Promise.resolve({ ok: true }) + }) + } + + it('emits drift without mutating when the fetched base fast-forwards created HEAD', async () => { + const { runtime, worktreeBaseStatus } = createReconcileRuntime() + const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + const gitSpy = mockReconcileGit({}) + try { + await reconcileWithToken(runtime, token) + + expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], { + cwd: TEST_WORKTREE_PATH + }) + expect(worktreeBaseStatus).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'drift', + behind: 3, + recentSubjects: ['base commit 3', 'base commit 2'] + }) + ) + } finally { + gitSpy.mockRestore() + } + }) + + it('emits current when the fetched base still matches created HEAD', async () => { + const { runtime, worktreeBaseStatus } = createReconcileRuntime() + const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + const gitSpy = mockReconcileGit({ postFetchSha: 'created-base-sha' }) + try { + await reconcileWithToken(runtime, token) + + expect(worktreeBaseStatus).toHaveBeenCalledWith({ + repoId: TEST_REPO_ID, + worktreeId: TEST_WORKTREE_ID, + base: 'origin/main', + remote: 'origin', + status: 'current' + }) + } finally { + gitSpy.mockRestore() + } + }) + + it('emits base_changed without mutation when the fetched base rewrote history', async () => { + const { runtime, worktreeBaseStatus } = createReconcileRuntime() + const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + const gitSpy = mockReconcileGit({ ancestor: false }) + try { + await reconcileWithToken(runtime, token) + + expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], { + cwd: TEST_WORKTREE_PATH + }) + expect(worktreeBaseStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'base_changed' }) + ) + } finally { + gitSpy.mockRestore() + } + }) + + it('skips stale-token reconciles without mutating or emitting stale status', async () => { + const stale = createReconcileRuntime() + const staleToken = stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + const staleGitSpy = mockReconcileGit({}) + try { + await reconcileWithToken(stale.runtime, staleToken) + expect(stale.worktreeBaseStatus).not.toHaveBeenCalled() + expect(staleGitSpy).not.toHaveBeenCalled() + } finally { + staleGitSpy.mockRestore() + } + }) + + it('emits unknown without mutation when fetch fails or the base ref is missing', async () => { + const fetchFailure = createReconcileRuntime() + const fetchFailureToken = fetchFailure.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + await fetchFailure.runtime.reconcileWorktreeBaseStatus({ + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreeId: TEST_WORKTREE_ID, + base: remoteTrackingBase, + branchName: 'feature', + createdBaseSha: 'created-base-sha', + token: fetchFailureToken, + fetchPromise: Promise.resolve({ ok: false, errorKind: 'git_error' }) + }) + expect(fetchFailure.worktreeBaseStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'unknown' }) + ) + + const missingBase = createReconcileRuntime() + const missingBaseToken = missingBase.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID) + const gitSpy = mockReconcileGit({ baseRefMissing: true }) + try { + await reconcileWithToken(missingBase.runtime, missingBaseToken) + expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], { + cwd: TEST_WORKTREE_PATH + }) + expect(missingBase.worktreeBaseStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'unknown' }) + ) + } finally { + gitSpy.mockRestore() + } + }) + it('invalidates the filesystem-auth cache after CLI worktree creation', async () => { // Reproduces: CLI-created worktrees fail with "Access denied: unknown // repository or worktree path" because the filesystem-auth cache was diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ec76763d939..82510a2b884 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -925,16 +925,21 @@ export class OrcaRuntimeService { // vice-versa. Keyed by `::` so multi-remote repos (even // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map // also provides serialization — two concurrent callers share a single - // underlying `git fetch`. Lifecycle rules are enforced in - // `fetchRemoteWithCache` and MUST NOT be duplicated elsewhere: + // underlying `git fetch`. Full-remote fetch lifecycle rules: // - entry inserted BEFORE await, // - `.finally()` removes the entry on BOTH success and rejection, // - timestamp written ONLY on success (rejection must not make the // 30s freshness cache lie). // A literal "insert before await / read-back after await" without these - // three rules wedges all future creates on the same repo after a single - // DNS hiccup until process restart (see §3.3 Lifecycle). + // three rules wedges future fetches on the same repo after a single + // DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref + // refreshes share the in-flight rule but intentionally do not write the + // full-remote freshness timestamp. private fetchInflight = new Map>() + // Why: `git fetch origin` and `git fetch origin ` contend for the + // same repo remote/ref locks. This queue serializes all fetch shapes for one + // canonical repo+remote while still letting same-shape callers share promises. + private remoteFetchQueueTail = new Map>() private fetchLastCompletedAt = new Map() // Why: `getCanonicalFetchKey` is awaited from every freshness probe and // every getOrStartRemoteFetch call. Without memoization the warm-cache hot @@ -5758,19 +5763,33 @@ export class OrcaRuntimeService { const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot) - const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - // Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back - // CLI creates on the same repo don't each pay the round-trip, and so a - // subsequent dispatch probe within the 30s window reuses this result. The - // helper swallows rejection (log-and-proceed) so a DNS hiccup never wedges - // future creates and CLI creation stays usable offline — same intent as - // the previous try/catch around gitExecFileSync. - try { - await this.fetchRemoteWithCache(repo.path, remote) - } catch { - // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does - // not throw; the outer try/catch guarantees create-path tolerance even - // if future refactors change that contract. + const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch) + if (remoteTrackingBase) { + const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase) + const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase + ) + if (!refreshResult.ok) { + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + if (!hadLocalBaseRef && !(await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase))) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } + } else { + const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' + // Why: local bases keep legacy best-effort fetch behavior. Remote-tracking + // bases fail closed above because stale create-from-base is worse than a + // clear retryable error. + try { + await this.fetchRemoteWithCache(repo.path, remote) + } catch { + // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does + // not throw; the outer try/catch guarantees create-path tolerance even + // if future refactors change that contract. + } } const sparseDirectories = args.sparseCheckout @@ -6123,9 +6142,8 @@ export class OrcaRuntimeService { /** * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight - * serialization with all other callers (renderer-create path, CLI create, - * dispatch drift probe). Never rejects — callers log-and-proceed on offline - * failures (§3.3 Lifecycle). + * serialization with all other callers. Never rejects — callers + * log-and-proceed on offline failures (§3.3 Lifecycle). * * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A @@ -6157,10 +6175,19 @@ export class OrcaRuntimeService { return resolved } - async isRemoteFetchFresh(repoPath: string, remote: string): Promise { - const key = await this.getCanonicalFetchKey(repoPath, remote) - const lastAt = this.fetchLastCompletedAt.get(key) - return lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS + private enqueueRemoteFetch( + remoteKey: string, + runFetch: () => Promise + ): Promise { + const previous = this.remoteFetchQueueTail.get(remoteKey) + const promise = previous ? previous.then(runFetch, runFetch) : runFetch() + this.remoteFetchQueueTail.set(remoteKey, promise) + promise.finally(() => { + if (this.remoteFetchQueueTail.get(remoteKey) === promise) { + this.remoteFetchQueueTail.delete(remoteKey) + } + }) + return promise } async getOrStartRemoteFetch(repoPath: string, remote: string): Promise { @@ -6180,26 +6207,59 @@ export class OrcaRuntimeService { return existing } - const promise = gitExecFileAsync(['fetch', remote], { cwd: repoPath }) - .then((): RemoteFetchResult => { - // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection - // would make the freshness cache lie about the last known remote state. - this.fetchLastCompletedAt.set(key, Date.now()) - return { ok: true } - }) - .catch((err): RemoteFetchResult => { - // Why: swallow here so awaiters don't throw at the await site. Outer - // create/dispatch paths are already tolerant of offline fetch failure; - // this is the behavioral contract of this helper. - console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) - return { ok: false, errorKind: 'git_error' } - }) - .finally(() => { - // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A - // rejected entry that survived in the Map would wedge every future - // create on this repo until Orca restarted (the F2 bug §3.3 pins). - this.fetchInflight.delete(key) - }) + const promise = this.enqueueRemoteFetch(key, () => + gitExecFileAsync(['fetch', remote], { cwd: repoPath }) + .then((): RemoteFetchResult => { + // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection + // would make the freshness cache lie about the last known remote state. + this.fetchLastCompletedAt.set(key, Date.now()) + return { ok: true } + }) + .catch((err): RemoteFetchResult => { + // Why: swallow here so awaiters don't throw at the await site. Outer + // create/dispatch paths are already tolerant of offline fetch failure; + // this is the behavioral contract of this helper. + console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) + return { ok: false, errorKind: 'git_error' } + }) + ).finally(() => { + // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A + // rejected entry that survived in the Map would wedge every future + // create on this repo until Orca restarted (the F2 bug §3.3 pins). + this.fetchInflight.delete(key) + }) + + this.fetchInflight.set(key, promise) + return promise + } + + async getOrStartRemoteTrackingBaseRefresh( + repoPath: string, + base: RemoteTrackingBase + ): Promise { + const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote) + const key = await this.getCanonicalFetchKey(repoPath, `base:${base.remote}:${base.branch}`) + const existing = this.fetchInflight.get(key) + if (existing) { + return existing + } + + const promise = this.enqueueRemoteFetch(remoteKey, () => + gitExecFileAsync( + ['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`], + { cwd: repoPath } + ) + .then((): RemoteFetchResult => ({ ok: true })) + .catch((err): RemoteFetchResult => { + console.warn( + `[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`, + err + ) + return { ok: false, errorKind: 'git_error' } + }) + ).finally(() => { + this.fetchInflight.delete(key) + }) this.fetchInflight.set(key, promise) return promise