+
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
@@ -1608,6 +1848,8 @@ export default function CombinedDiffViewer({
void | (() => void)
+ }[] = []
let refIndex = 0
return {
@@ -13,8 +16,8 @@ function createReactHookHarness() {
effects,
react: {
useCallback:
unknown>(callback: T): T => callback,
- useLayoutEffect: (_effect: () => void | (() => void), deps?: readonly unknown[]) => {
- effects.push({ deps })
+ useLayoutEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => {
+ effects.push({ deps, effect })
},
useMemo: (factory: () => T): T => factory(),
useRef: (initialValue: T): { current: T } => {
@@ -69,4 +72,99 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => {
expect(initialDeps).toEqual([scrollElementRef, scrollOffsetRef])
expect(nextDeps).toEqual(initialDeps)
})
+
+ it('keeps the target anchor while measured fallback restores a transitional window', async () => {
+ const harness = createReactHookHarness()
+ vi.doMock('react', () => harness.react)
+ const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor')
+
+ const anchorRef = { current: { key: 'row-1', offset: 3358 } }
+ const scrollElementRef = {
+ current: {
+ clientHeight: 880,
+ scrollHeight: 30_000,
+ scrollTop: 0,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ }
+ }
+ const scrollOffsetRef = { current: 0 }
+ const virtualizer = {
+ getVirtualItems: () => [
+ { index: 8, start: 0, end: 30_000 },
+ { index: 1, start: 758, end: 4512 }
+ ],
+ isScrolling: false,
+ scrollToIndex: vi.fn()
+ }
+
+ harness.beginRender()
+ // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly.
+ useVirtualizedScrollAnchor({
+ anchorRef,
+ getRowKey: (row) => row,
+ rows: Array.from({ length: 9 }, (_, index) => `row-${index}`),
+ scrollElementRef,
+ scrollOffsetRef,
+ totalSize: 30_000,
+ virtualizer
+ } as never)
+
+ harness.effects[1]?.effect()
+
+ expect(scrollElementRef.current.scrollTop).toBe(4116)
+ expect(anchorRef.current).toEqual({ key: 'row-1', offset: 3358 })
+ })
+
+ it('can ignore generic scroll anchor recording while preserving the saved anchor', async () => {
+ const harness = createReactHookHarness()
+ vi.doMock('react', () => harness.react)
+ const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor')
+
+ const capturedScrollHandler: { current: (() => void) | null } = { current: null }
+ const savedAnchor = { key: 'row-1', offset: 3358 }
+ const anchorRef = { current: savedAnchor }
+ const scrollElementRef = {
+ current: {
+ clientHeight: 880,
+ scrollHeight: 30_000,
+ scrollTop: 746,
+ addEventListener: vi.fn((eventName: string, handler: () => void) => {
+ if (eventName === 'scroll') {
+ capturedScrollHandler.current = handler
+ }
+ }),
+ removeEventListener: vi.fn()
+ }
+ }
+ const scrollOffsetRef = { current: 0 }
+ const virtualizer = {
+ getVirtualItems: () => [
+ { index: 0, start: 0, end: 3_000 },
+ { index: 1, start: 3_000, end: 7_000 }
+ ],
+ isScrolling: false,
+ scrollToIndex: vi.fn()
+ }
+
+ harness.beginRender()
+ // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly.
+ useVirtualizedScrollAnchor({
+ anchorRef,
+ getRowKey: (row) => row,
+ recordAnchorOnScroll: false,
+ rows: ['row-0', 'row-1'],
+ scrollElementRef,
+ scrollOffsetRef,
+ totalSize: 30_000,
+ virtualizer
+ } as never)
+
+ harness.effects[0]?.effect()
+ expect(capturedScrollHandler.current).not.toBeNull()
+ capturedScrollHandler.current?.()
+
+ expect(scrollOffsetRef.current).toBe(0)
+ expect(anchorRef.current).toBe(savedAnchor)
+ })
})
diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts
index d94ef02199f..7dd9764e7f6 100644
--- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts
+++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts
@@ -8,6 +8,10 @@ import {
} from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore'
+import {
+ findVirtualizedDomScrollAnchor,
+ getVirtualizedScrollAnchorForOffset
+} from './virtualized-scroll-anchor-recording'
export type VirtualizedScrollAnchor = {
fallbackKeys?: readonly string[]
@@ -27,6 +31,10 @@ type UseVirtualizedScrollAnchorOptions<
getRowKey: (row: TRow) => string
hasDirectScrollInput?: () => boolean
itemElementSelector?: string
+ recordAnchorOnCleanup?: boolean
+ // Why: some callers record user scroll anchors outside this hook; passive
+ // programmatic scroll events during remount must not teach them a transient row.
+ recordAnchorOnScroll?: boolean
rows: readonly TRow[]
scrollElementRef: RefObject
scrollOffsetRef: MutableRefObject
@@ -53,6 +61,8 @@ export function useVirtualizedScrollAnchor<
getRowKey,
hasDirectScrollInput,
itemElementSelector,
+ recordAnchorOnCleanup = true,
+ recordAnchorOnScroll = true,
rows,
scrollElementRef,
scrollOffsetRef,
@@ -68,64 +78,14 @@ export function useVirtualizedScrollAnchor<
return indexByKey
}, [getRowKey, rows])
- const findDomAnchor = useCallback(
- (scrollElement: TScrollElement) => {
- if (!itemElementSelector || !getItemElementKey) {
- return null
- }
- const scrollRect = scrollElement.getBoundingClientRect()
- type DomAnchorItem = { element: TItemElement; key: string; rect: DOMRect }
- const visibleItems = Array.from(
- scrollElement.querySelectorAll(itemElementSelector)
- )
- .map((element) => {
- const key = getItemElementKey(element)
- if (!key || !rowIndexByKey.has(key) || !element.isConnected) {
- return null
- }
- const rect = element.getBoundingClientRect()
- if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) {
- return null
- }
- return { element, key, rect }
- })
- .filter((item): item is DomAnchorItem => item != null)
- .sort((a, b) => a.rect.top - b.rect.top)
-
- const [firstVisible] = visibleItems
- if (!firstVisible) {
- return null
- }
- return {
- fallbackKeys: visibleItems.slice(1).map((item) => item.key),
- key: firstVisible.key,
- offset: Math.min(
- firstVisible.rect.height,
- Math.max(0, scrollRect.top - firstVisible.rect.top)
- )
- }
- },
- [getItemElementKey, itemElementSelector, rowIndexByKey]
- )
-
const recordVirtualScrollAnchor = useCallback(
(scrollTop: number) => {
- const virtualItems = virtualizer.getVirtualItems()
- const firstVisible = virtualItems.find((item) => item.end > scrollTop)
- const row = firstVisible ? rows[firstVisible.index] : undefined
- if (!firstVisible || !row) {
- anchorRef.current = null
- return
- }
- anchorRef.current = {
- fallbackKeys: virtualItems
- .slice(virtualItems.indexOf(firstVisible) + 1)
- .map((item) => rows[item.index])
- .filter((row): row is TRow => row != null)
- .map(getRowKey),
- key: getRowKey(row),
- offset: Math.max(0, scrollTop - firstVisible.start)
- }
+ anchorRef.current = getVirtualizedScrollAnchorForOffset({
+ getRowKey,
+ rows,
+ scrollTop,
+ virtualItems: virtualizer.getVirtualItems()
+ })
},
[anchorRef, getRowKey, rows, virtualizer]
)
@@ -133,17 +93,36 @@ export function useVirtualizedScrollAnchor<
const recordScrollAnchor = useCallback(
(scrollTop: number) => {
const scrollElement = scrollElementRef.current
- if (scrollElement) {
- const domAnchor = findDomAnchor(scrollElement)
+ if (scrollElement && itemElementSelector && getItemElementKey) {
+ const domAnchor = findVirtualizedDomScrollAnchor({
+ getItemElementKey,
+ itemElementSelector,
+ rowIndexByKey,
+ scrollElement
+ })
if (domAnchor) {
anchorRef.current = domAnchor
return
}
}
- recordVirtualScrollAnchor(scrollTop)
+ anchorRef.current = getVirtualizedScrollAnchorForOffset({
+ getRowKey,
+ rows,
+ scrollTop,
+ virtualItems: virtualizer.getVirtualItems()
+ })
},
- [anchorRef, findDomAnchor, recordVirtualScrollAnchor, scrollElementRef]
+ [
+ anchorRef,
+ getItemElementKey,
+ getRowKey,
+ itemElementSelector,
+ rowIndexByKey,
+ rows,
+ scrollElementRef,
+ virtualizer
+ ]
)
// Why: row changes must not re-register the scroll listener; cleanup records
@@ -154,6 +133,10 @@ export function useVirtualizedScrollAnchor<
recordVirtualScrollAnchorRef.current = recordVirtualScrollAnchor
const hasDirectScrollInputRef = useRef(hasDirectScrollInput)
hasDirectScrollInputRef.current = hasDirectScrollInput
+ const recordAnchorOnCleanupRef = useRef(recordAnchorOnCleanup)
+ recordAnchorOnCleanupRef.current = recordAnchorOnCleanup
+ const recordAnchorOnScrollRef = useRef(recordAnchorOnScroll)
+ recordAnchorOnScrollRef.current = recordAnchorOnScroll
useLayoutEffect(() => {
const el = scrollElementRef.current
@@ -216,18 +199,29 @@ export function useVirtualizedScrollAnchor<
// user's real position until the intended offset is reachable.
if (el.scrollTop === targetOffset) {
restoring = false
- recordCurrentAnchor()
+ if (recordAnchorOnScrollRef.current) {
+ recordCurrentAnchor()
+ } else {
+ scrollOffsetRef.current = el.scrollTop
+ }
return
}
if (el.scrollHeight - el.clientHeight >= targetOffset) {
el.scrollTop = targetOffset
if (el.scrollTop === targetOffset) {
restoring = false
- recordCurrentAnchor()
+ if (recordAnchorOnScrollRef.current) {
+ recordCurrentAnchor()
+ } else {
+ scrollOffsetRef.current = el.scrollTop
+ }
}
}
return
}
+ if (!recordAnchorOnScrollRef.current) {
+ return
+ }
scrollOffsetRef.current = el.scrollTop
recordVirtualScrollAnchorRef.current(el.scrollTop)
scheduleRecordAnchor()
@@ -237,8 +231,10 @@ export function useVirtualizedScrollAnchor<
el.addEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor)
return () => {
cancelScheduledRecord()
- scrollOffsetRef.current = el.scrollTop
- recordScrollAnchorRef.current(el.scrollTop)
+ if (recordAnchorOnCleanupRef.current) {
+ scrollOffsetRef.current = el.scrollTop
+ recordScrollAnchorRef.current(el.scrollTop)
+ }
el.removeEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor)
el.removeEventListener('scroll', onScroll)
}
@@ -250,9 +246,10 @@ export function useVirtualizedScrollAnchor<
if (!anchor || !el) {
return
}
- if (virtualizer.isScrolling) {
+ if (virtualizer.isScrolling && hasDirectScrollInputRef.current?.() === true) {
// Why: remeasurement during wheel scrolling can change totalSize. Restoring
// the anchor in that window writes scrollTop and fights the user's wheel.
+ // Programmatic scrolls during remount still need anchor correction.
return
}
if (shouldSkipRestore?.()) {
@@ -287,7 +284,11 @@ export function useVirtualizedScrollAnchor<
const desiredTop = scrollRect.top - offset
const delta = rect.top - desiredTop
if (Math.abs(delta) > 1) {
+ // Why: this scroll write is still part of restore; keep the target
+ // anchor until a later layout confirms the intended row is in place.
el.scrollTop += delta
+ scrollOffsetRef.current = el.scrollTop
+ return true
}
scrollOffsetRef.current = el.scrollTop
recordScrollAnchor(el.scrollTop)
@@ -304,8 +305,9 @@ export function useVirtualizedScrollAnchor<
if (Math.abs(el.scrollTop - nextScrollTop) > 1) {
el.scrollTop = nextScrollTop
}
+ // Why: measured fallback can run while TanStack's virtual window is
+ // transitional, so recording here can replace the target with a wrong row.
scrollOffsetRef.current = el.scrollTop
- recordScrollAnchor(el.scrollTop)
return true
}
diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts
new file mode 100644
index 00000000000..bf77d2426ff
--- /dev/null
+++ b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts
@@ -0,0 +1,75 @@
+import type { VirtualizedScrollAnchor } from './useVirtualizedScrollAnchor'
+
+type VirtualScrollItem = {
+ end: number
+ index: number
+ start: number
+}
+
+export function findVirtualizedDomScrollAnchor({
+ getItemElementKey,
+ itemElementSelector,
+ rowIndexByKey,
+ scrollElement
+}: {
+ getItemElementKey: (element: TItemElement) => string | null
+ itemElementSelector: string
+ rowIndexByKey: ReadonlyMap
+ scrollElement: Element
+}): NonNullable | null {
+ const scrollRect = scrollElement.getBoundingClientRect()
+ type DomAnchorItem = { key: string; rect: DOMRect }
+ const visibleItems = Array.from(scrollElement.querySelectorAll(itemElementSelector))
+ .map((element) => {
+ const key = getItemElementKey(element)
+ if (!key || !rowIndexByKey.has(key) || !element.isConnected) {
+ return null
+ }
+ const rect = element.getBoundingClientRect()
+ if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) {
+ return null
+ }
+ return { key, rect }
+ })
+ .filter((item): item is DomAnchorItem => item != null)
+ .sort((a, b) => a.rect.top - b.rect.top)
+
+ const [firstVisible] = visibleItems
+ if (!firstVisible) {
+ return null
+ }
+
+ return {
+ fallbackKeys: visibleItems.slice(1).map((item) => item.key),
+ key: firstVisible.key,
+ offset: Math.min(firstVisible.rect.height, Math.max(0, scrollRect.top - firstVisible.rect.top))
+ }
+}
+
+export function getVirtualizedScrollAnchorForOffset({
+ getRowKey,
+ rows,
+ scrollTop,
+ virtualItems
+}: {
+ getRowKey: (row: TRow) => string
+ rows: readonly TRow[]
+ scrollTop: number
+ virtualItems: readonly VirtualScrollItem[]
+}): VirtualizedScrollAnchor {
+ const firstVisible = virtualItems.find((item) => item.end > scrollTop)
+ const row = firstVisible ? rows[firstVisible.index] : undefined
+ if (!firstVisible || !row) {
+ return null
+ }
+
+ return {
+ fallbackKeys: virtualItems
+ .slice(virtualItems.indexOf(firstVisible) + 1)
+ .map((item) => rows[item.index])
+ .filter((row): row is TRow => row != null)
+ .map(getRowKey),
+ key: getRowKey(row),
+ offset: Math.max(0, scrollTop - firstVisible.start)
+ }
+}
diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts
index 358d9b9598c..4bbcc75cd2d 100644
--- a/src/renderer/src/store/slices/tabs.test.ts
+++ b/src/renderer/src/store/slices/tabs.test.ts
@@ -851,6 +851,48 @@ describe('TabsSlice', () => {
// on that group's active terminal tab. Without this, the bell lingers
// until the tab is clicked a second time.
describe('focusGroup', () => {
+ it('does not broadcast active-surface writes when the focused group is already current', () => {
+ const editorFileId = '/tmp/feature/src/main.ts'
+ const tab = store.getState().createUnifiedTab(WT, 'editor', {
+ id: 'editor-tab-1',
+ entityId: editorFileId,
+ label: 'main.ts'
+ })
+ const groupId = store.getState().groupsByWorktree[WT][0].id
+ store.setState({
+ activeWorktreeId: WT,
+ openFiles: [makeOpenFile({ id: editorFileId, worktreeId: WT })],
+ activeGroupIdByWorktree: { [WT]: groupId },
+ activeFileId: editorFileId,
+ activeFileIdByWorktree: { [WT]: editorFileId },
+ activeBrowserTabId: null,
+ activeBrowserTabIdByWorktree: { [WT]: null },
+ activeTabId: null,
+ activeTabIdByWorktree: { [WT]: null },
+ activeTabType: 'editor',
+ activeTabTypeByWorktree: { [WT]: 'editor' },
+ groupsByWorktree: {
+ [WT]: [
+ {
+ ...store.getState().groupsByWorktree[WT][0],
+ activeTabId: tab.id
+ }
+ ]
+ }
+ })
+ const before = store.getState()
+ const listener = vi.fn()
+ const unsubscribe = store.subscribe(listener)
+
+ store.getState().focusGroup(WT, groupId)
+ unsubscribe()
+
+ expect(listener).not.toHaveBeenCalled()
+ expect(store.getState().activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree)
+ expect(store.getState().activeFileIdByWorktree).toBe(before.activeFileIdByWorktree)
+ expect(store.getState().activeTabTypeByWorktree).toBe(before.activeTabTypeByWorktree)
+ })
+
// Why: focusGroup is fired on every pointerdown within a split group's
// chrome (onPointerDown + onFocusCapture in TabGroupPanel). Clearing the
// tab-level bell here is fine — the user is now looking at this group.
diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts
index 04e83253973..fd54c54cde7 100644
--- a/src/renderer/src/store/slices/tabs.ts
+++ b/src/renderer/src/store/slices/tabs.ts
@@ -534,6 +534,44 @@ function buildActiveSurfacePatch(
}
}
+function activeSurfacePatchMatchesState(
+ state: Pick<
+ AppState,
+ | 'activeBrowserTabId'
+ | 'activeBrowserTabIdByWorktree'
+ | 'activeFileId'
+ | 'activeFileIdByWorktree'
+ | 'activeTabId'
+ | 'activeTabIdByWorktree'
+ | 'activeTabType'
+ | 'activeTabTypeByWorktree'
+ >,
+ worktreeId: string,
+ patch: Pick<
+ AppState,
+ | 'activeBrowserTabId'
+ | 'activeBrowserTabIdByWorktree'
+ | 'activeFileId'
+ | 'activeFileIdByWorktree'
+ | 'activeTabId'
+ | 'activeTabIdByWorktree'
+ | 'activeTabType'
+ | 'activeTabTypeByWorktree'
+ >
+): boolean {
+ return (
+ state.activeBrowserTabId === patch.activeBrowserTabId &&
+ state.activeBrowserTabIdByWorktree[worktreeId] ===
+ patch.activeBrowserTabIdByWorktree[worktreeId] &&
+ state.activeFileId === patch.activeFileId &&
+ state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] &&
+ state.activeTabId === patch.activeTabId &&
+ state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] &&
+ state.activeTabType === patch.activeTabType &&
+ state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId]
+ )
+}
+
export const createTabsSlice: StateCreator = (set, get) => ({
unifiedTabsByWorktree: {},
renamingTabId: null,
@@ -1171,10 +1209,13 @@ export const createTabsSlice: StateCreator = (set,
focusGroup: (worktreeId, groupId) =>
set((state) => {
- const nextActiveGroupIdByWorktree = {
- ...state.activeGroupIdByWorktree,
- [worktreeId]: groupId
- }
+ const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId
+ const nextActiveGroupIdByWorktree = groupAlreadyFocused
+ ? state.activeGroupIdByWorktree
+ : {
+ ...state.activeGroupIdByWorktree,
+ [worktreeId]: groupId
+ }
// Why: focusing a split group surfaces whichever terminal tab is already
// active in that group, so the tab-level bell is no longer needed.
//
@@ -1185,6 +1226,9 @@ export const createTabsSlice: StateCreator = (set,
// before the user ever sees the tab. All current callers only fire for
// the active worktree, but this guard prevents future misuse.
if (state.activeWorktreeId !== worktreeId) {
+ if (groupAlreadyFocused) {
+ return state
+ }
return {
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
}
@@ -1214,8 +1258,23 @@ export const createTabsSlice: StateCreator = (set,
return changed ? copy : state.unreadTerminalTabs
})()
: state.unreadTerminalTabs
+ const activeSurfacePatch = buildActiveSurfacePatch(
+ {
+ ...state,
+ activeGroupIdByWorktree: nextActiveGroupIdByWorktree
+ },
+ worktreeId,
+ groupId
+ )
+ if (
+ groupAlreadyFocused &&
+ nextUnreadTerminalTabs === state.unreadTerminalTabs &&
+ activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch)
+ ) {
+ return state
+ }
return {
- activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
+ ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }),
// Why: only write unreadTerminalTabs back into state when it actually
// changed. The IIFE above returns state.unreadTerminalTabs by reference
// on no-op; preserving that reference via conditional spread keeps
@@ -1224,14 +1283,7 @@ export const createTabsSlice: StateCreator = (set,
...(nextUnreadTerminalTabs !== state.unreadTerminalTabs
? { unreadTerminalTabs: nextUnreadTerminalTabs }
: {}),
- ...buildActiveSurfacePatch(
- {
- ...state,
- activeGroupIdByWorktree: nextActiveGroupIdByWorktree
- },
- worktreeId,
- groupId
- )
+ ...activeSurfacePatch
}
}),
diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts
new file mode 100644
index 00000000000..191f7810f4b
--- /dev/null
+++ b/tests/e2e/combined-diff-scroll-restore.spec.ts
@@ -0,0 +1,403 @@
+import { execFileSync } from 'child_process'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
+import os from 'os'
+import path from 'path'
+import type { Page } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import { waitForSessionReady } from './helpers/store'
+
+type CombinedDiffScrollRepo = {
+ repoPath: string
+}
+
+type ViewportAnchor = {
+ key: string
+ index: number
+ top: number
+ bottom: number
+ scrollTop: number
+ scrollHeight: number
+ clientHeight: number
+}
+
+type ScrollProbeSample = {
+ scrollHeight: number
+ scrollTop: number
+}
+
+const FILE_COUNT = 18
+const ADDED_LINES_PER_FILE = 180
+
+function runGit(repoPath: string, args: string[]): void {
+ execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' })
+}
+
+function buildBaseFile(fileIndex: number): string {
+ return `${Array.from(
+ { length: 20 },
+ (_, lineIndex) => `export const base_${fileIndex}_${lineIndex} = ${lineIndex}`
+ ).join('\n')}\n`
+}
+
+function buildModifiedFile(fileIndex: number): string {
+ const added = Array.from(
+ { length: ADDED_LINES_PER_FILE },
+ (_, lineIndex) => `export const changed_${fileIndex}_${lineIndex} = ${fileIndex + lineIndex}`
+ ).join('\n')
+ return `${buildBaseFile(fileIndex)}${added}\n`
+}
+
+function createCombinedDiffScrollRepo(): CombinedDiffScrollRepo {
+ const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-combined-diff-scroll-')))
+ runGit(repoPath, ['init'])
+ runGit(repoPath, ['config', 'user.email', 'e2e@test.local'])
+ runGit(repoPath, ['config', 'user.name', 'E2E Test'])
+
+ const srcDir = path.join(repoPath, 'src')
+ mkdirSync(srcDir, { recursive: true })
+ for (let index = 0; index < FILE_COUNT; index += 1) {
+ writeFileSync(
+ path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`),
+ buildBaseFile(index)
+ )
+ }
+ runGit(repoPath, ['add', '-A'])
+ runGit(repoPath, ['commit', '-m', 'Initial combined diff scroll fixture'])
+
+ for (let index = 0; index < FILE_COUNT; index += 1) {
+ writeFileSync(
+ path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`),
+ buildModifiedFile(index)
+ )
+ }
+
+ return { repoPath }
+}
+
+async function addAndActivateRepo(page: Page, repoPath: string): Promise {
+ const repoId = await page.evaluate(async (pathToRepo: string) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+
+ const addedRepo = await store.getState().addRepoPath(pathToRepo)
+ if (!addedRepo) {
+ throw new Error(`isolated combined-diff repo not found: ${pathToRepo}`)
+ }
+ return addedRepo.id
+ }, repoPath)
+
+ await expect
+ .poll(
+ () =>
+ page.evaluate(async (targetRepoId: string) => {
+ const store = window.__store
+ if (!store) {
+ return 0
+ }
+ await store.getState().fetchWorktrees(targetRepoId)
+ return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0
+ }, repoId),
+ {
+ timeout: 30_000,
+ message: 'isolated combined-diff worktree did not load'
+ }
+ )
+ .toBeGreaterThan(0)
+
+ return page.evaluate(
+ ({ targetRepoId, pathToRepo }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+
+ const state = store.getState()
+ const worktrees = state.worktreesByRepo[targetRepoId] ?? []
+ const worktree = worktrees.find((entry) => entry.path === pathToRepo) ?? worktrees[0]
+ if (!worktree) {
+ throw new Error(`isolated combined-diff worktree not found: ${pathToRepo}`)
+ }
+ state.setActiveRepo(targetRepoId)
+ state.setActiveWorktree(worktree.id)
+ return worktree.id
+ },
+ { targetRepoId: repoId, pathToRepo: repoPath }
+ )
+}
+
+async function openCombinedDiff(page: Page, worktreeId: string, repoPath: string): Promise {
+ return page.evaluate(
+ async ({ wId, pathToRepo }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+ const state = store.getState()
+ const status = await window.api.git.status({ worktreePath: pathToRepo })
+ const entries = status.entries.filter((entry) => entry.area === 'unstaged')
+ if (entries.length < 2) {
+ throw new Error(`expected multiple unstaged entries, received ${entries.length}`)
+ }
+ state.setGitStatus(wId, status)
+ state.openAllDiffs(wId, pathToRepo, undefined, 'unstaged', entries)
+
+ const nextState = store.getState()
+ const activeGroupId = nextState.activeGroupIdByWorktree[wId]
+ const activeFileId = nextState.activeFileId
+ const tab = (nextState.unifiedTabsByWorktree[wId] ?? []).find(
+ (candidate) => candidate.groupId === activeGroupId && candidate.entityId === activeFileId
+ )
+ if (!tab) {
+ throw new Error('combined diff tab was not created')
+ }
+ return tab.id
+ },
+ { wId: worktreeId, pathToRepo: repoPath }
+ )
+}
+
+async function scrollCombinedDiffDeep(page: Page): Promise {
+ await page.evaluate(() => {
+ const container = document.querySelector('.combined-diff-scroll-container')
+ if (!container) {
+ throw new Error('combined diff scroll container not found')
+ }
+ const target = Math.min(7_000, Math.max(0, container.scrollHeight - container.clientHeight - 1))
+ container.dispatchEvent(
+ new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: target })
+ )
+ container.scrollTop = target
+ container.dispatchEvent(new Event('scroll', { bubbles: true }))
+ })
+}
+
+async function readViewportAnchor(page: Page): Promise {
+ return page.evaluate(() => {
+ const container = document.querySelector('.combined-diff-scroll-container')
+ if (!container) {
+ return null
+ }
+ const containerRect = container.getBoundingClientRect()
+ const visibleRows = Array.from(
+ container.querySelectorAll('[data-combined-diff-section-row]')
+ )
+ .map((row) => {
+ const rect = row.getBoundingClientRect()
+ const key = row.dataset.combinedDiffSectionKey
+ const index = Number(row.dataset.index)
+ if (
+ !key ||
+ !Number.isFinite(index) ||
+ rect.height <= 0 ||
+ rect.bottom <= containerRect.top ||
+ rect.top >= containerRect.bottom
+ ) {
+ return null
+ }
+ return {
+ key,
+ index,
+ top: rect.top - containerRect.top,
+ bottom: rect.bottom - containerRect.top,
+ scrollTop: container.scrollTop,
+ scrollHeight: container.scrollHeight,
+ clientHeight: container.clientHeight
+ }
+ })
+ .filter((row): row is ViewportAnchor => row !== null)
+ .sort((a, b) => a.top - b.top)
+ return visibleRows[0] ?? null
+ })
+}
+
+async function waitForStableViewportAnchor(page: Page): Promise {
+ const startedAt = Date.now()
+ let lastSignature = ''
+ let stableSamples = 0
+ let lastAnchor: ViewportAnchor | null = null
+
+ while (Date.now() - startedAt < 15_000) {
+ const anchor = await readViewportAnchor(page)
+ if (anchor) {
+ const signature = `${anchor.key}:${Math.round(anchor.top)}:${Math.round(
+ anchor.bottom
+ )}:${Math.round(anchor.scrollHeight)}`
+ if (signature === lastSignature) {
+ stableSamples += 1
+ if (stableSamples >= 2) {
+ return anchor
+ }
+ } else {
+ lastSignature = signature
+ stableSamples = 0
+ }
+ lastAnchor = anchor
+ }
+ await page.waitForTimeout(100)
+ }
+
+ throw new Error(`combined diff viewport anchor did not settle: ${JSON.stringify(lastAnchor)}`)
+}
+
+async function startCombinedDiffScrollProbe(page: Page): Promise {
+ await page.evaluate(() => {
+ type CombinedDiffScrollProbe = {
+ samples: ScrollProbeSample[]
+ stop: () => void
+ }
+ const targetWindow = window as typeof window & {
+ __combinedDiffScrollProbe?: CombinedDiffScrollProbe
+ }
+ targetWindow.__combinedDiffScrollProbe?.stop()
+
+ const container = document.querySelector('.combined-diff-scroll-container')
+ if (!container) {
+ throw new Error('combined diff scroll container not found')
+ }
+
+ const samples: ScrollProbeSample[] = []
+ const record = (): void => {
+ samples.push({
+ scrollHeight: container.scrollHeight,
+ scrollTop: container.scrollTop
+ })
+ }
+ container.addEventListener('scroll', record, { passive: true })
+ record()
+ targetWindow.__combinedDiffScrollProbe = {
+ samples,
+ stop: () => container.removeEventListener('scroll', record)
+ }
+ })
+}
+
+async function stopCombinedDiffScrollProbe(page: Page): Promise {
+ return page.evaluate(() => {
+ type CombinedDiffScrollProbe = {
+ samples: ScrollProbeSample[]
+ stop: () => void
+ }
+ const targetWindow = window as typeof window & {
+ __combinedDiffScrollProbe?: CombinedDiffScrollProbe
+ }
+ const probe = targetWindow.__combinedDiffScrollProbe
+ if (!probe) {
+ return []
+ }
+ probe.stop()
+ delete targetWindow.__combinedDiffScrollProbe
+ return probe.samples
+ })
+}
+
+async function wheelCombinedDiffDown(page: Page): Promise {
+ const container = page.locator('.combined-diff-scroll-container')
+ const box = await container.boundingBox()
+ if (!box) {
+ throw new Error('combined diff scroll container bounds not found')
+ }
+
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
+ await startCombinedDiffScrollProbe(page)
+ for (let index = 0; index < 12; index += 1) {
+ await page.mouse.wheel(0, 520)
+ await page.waitForTimeout(35)
+ }
+ await page.waitForTimeout(400)
+ return stopCombinedDiffScrollProbe(page)
+}
+
+function getLargestBackwardScrollJump(samples: readonly ScrollProbeSample[]): number {
+ let largestBackwardJump = 0
+ for (let index = 1; index < samples.length; index += 1) {
+ largestBackwardJump = Math.max(
+ largestBackwardJump,
+ samples[index - 1].scrollTop - samples[index].scrollTop
+ )
+ }
+ return largestBackwardJump
+}
+
+async function clickVisibleDiffLine(page: Page): Promise {
+ const linePoint = await page.evaluate(() => {
+ const container = document.querySelector('.combined-diff-scroll-container')
+ if (!container) {
+ throw new Error('combined diff scroll container not found')
+ }
+ const containerRect = container.getBoundingClientRect()
+ const visibleLine = Array.from(
+ container.querySelectorAll('.monaco-diff-editor .view-line')
+ ).find((line) => {
+ const rect = line.getBoundingClientRect()
+ return (
+ rect.height > 0 &&
+ rect.bottom > containerRect.top &&
+ rect.top < containerRect.bottom &&
+ rect.right > containerRect.left &&
+ rect.left < containerRect.right
+ )
+ })
+ if (!visibleLine) {
+ throw new Error('visible combined diff line not found')
+ }
+ const rect = visibleLine.getBoundingClientRect()
+ return {
+ x: rect.left + Math.min(12, Math.max(1, rect.width / 2)),
+ y: rect.top + rect.height / 2
+ }
+ })
+
+ await page.mouse.click(linePoint.x, linePoint.y)
+}
+
+test.describe('Combined diff scroll restore', () => {
+ test.describe.configure({ mode: 'serial' })
+ test.use({ seedTestRepo: false })
+
+ test('keeps the visible section anchored after switching tabs', async ({ orcaPage }) => {
+ await waitForSessionReady(orcaPage)
+ const fixture = createCombinedDiffScrollRepo()
+
+ try {
+ const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath)
+ const diffTabId = await openCombinedDiff(orcaPage, worktreeId, fixture.repoPath)
+ await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible()
+ await expect(orcaPage.getByText(`${FILE_COUNT} changed files`)).toBeVisible()
+
+ await scrollCombinedDiffDeep(orcaPage)
+ await waitForStableViewportAnchor(orcaPage)
+ const activeScrollSamples = await wheelCombinedDiffDown(orcaPage)
+ expect(activeScrollSamples.length).toBeGreaterThan(2)
+ expect(getLargestBackwardScrollJump(activeScrollSamples)).toBeLessThan(120)
+
+ const beforeSwitch = await waitForStableViewportAnchor(orcaPage)
+ expect(beforeSwitch.index).toBeGreaterThan(0)
+
+ await orcaPage.evaluate((wId) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+ store.getState().createTab(wId)
+ }, worktreeId)
+ await expect(orcaPage.locator('.combined-diff-scroll-container')).toHaveCount(0)
+
+ await orcaPage.locator(`[data-tab-id="${diffTabId}"]`).click({ force: true })
+ await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible()
+ const afterSwitch = await waitForStableViewportAnchor(orcaPage)
+
+ expect(afterSwitch.key).toBe(beforeSwitch.key)
+ expect(Math.abs(afterSwitch.top - beforeSwitch.top)).toBeLessThan(80)
+
+ await clickVisibleDiffLine(orcaPage)
+ const afterLineClick = await waitForStableViewportAnchor(orcaPage)
+
+ expect(afterLineClick.key).toBe(afterSwitch.key)
+ expect(Math.abs(afterLineClick.top - afterSwitch.top)).toBeLessThan(80)
+ } finally {
+ rmSync(fixture.repoPath, { recursive: true, force: true })
+ }
+ })
+})
From 4f6616ae6c6c0d091aa87bef89662b667cd9d68d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:22:26 -0700
Subject: [PATCH 10/18] Default session history to the current workspace
(#6211)
---
.../components/right-sidebar/AiVaultPanel.tsx | 33 ++++---
.../right-sidebar/AiVaultPanelControls.tsx | 7 +-
.../ai-vault-scope-state.test.ts | 93 +++++++++++++++++--
.../right-sidebar/ai-vault-scope-state.ts | 63 ++++++++++++-
4 files changed, 170 insertions(+), 26 deletions(-)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index 810ecdc8926..aa3d3d6fbfb 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -18,8 +18,9 @@ import {
groupAiVaultSessions
} from './ai-vault-session-filters'
import {
- normalizeAiVaultScopeForContext,
- shouldRestoreAiVaultProjectScope
+ DEFAULT_AI_VAULT_SCOPE,
+ getRestorableAiVaultScope,
+ normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { buildAiVaultProjectContext } from './ai-vault-session-projects'
import {
@@ -47,7 +48,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const projectHostSetupProjection = useProjectHostSetupProjection()
const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {})
const [query, setQuery] = useState('')
- const [scope, setScope] = useState('project')
+ const [scope, setScope] = useState(DEFAULT_AI_VAULT_SCOPE)
const [sort, setSort] = useState('updated')
const [group, setGroup] = useState('project')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
@@ -61,6 +62,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const userChangedScopeRef = useRef(false)
+ const preferredScopeRef = useRef(DEFAULT_AI_VAULT_SCOPE)
const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId)
const activeWorktreePath = activeWorktree?.path ?? null
@@ -91,8 +93,7 @@ export default function AiVaultPanel(): React.JSX.Element {
(group === 'project' ? 0 : 1) +
(hideEmptySessions ? 0 : 1)
- // Project scope depends on active project context, but should come back after
- // transient context loss unless the user intentionally chose another scope.
+ // Workspace is the preferred default, but unavailable context still falls back to All.
useEffect(() => {
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
@@ -105,16 +106,17 @@ export default function AiVaultPanel(): React.JSX.Element {
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
- if (
- shouldRestoreAiVaultProjectScope({
- scope,
- activeProjectKey,
- userChangedScope: userChangedScopeRef.current
- })
- ) {
- setScope('project')
+ const restorableScope = getRestorableAiVaultScope({
+ scope,
+ activeProjectKey,
+ activeWorktreePath,
+ preferredScope: preferredScopeRef.current,
+ userChangedScope: userChangedScopeRef.current
+ })
+ if (restorableScope) {
+ setScope(restorableScope)
}
- }, [activeProjectKey, scope])
+ }, [activeProjectKey, activeWorktreePath, scope])
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => {
if (refreshInFlightRef.current) {
@@ -284,7 +286,8 @@ export default function AiVaultPanel(): React.JSX.Element {
}, [])
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
- userChangedScopeRef.current = nextScope !== 'project'
+ preferredScopeRef.current = nextScope
+ userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
setScope(nextScope)
}, [])
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
index f9feda064f4..0b42428f6a0 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx
@@ -36,8 +36,11 @@ import { translate } from '@/i18n/i18n'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
-const VAULT_SCOPE_TOGGLE_ITEM_CLASS =
- 'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5'
+// Why: match ToggleGroup's spacing+outline qualifiers so selected edges out-specify its border-l-0 collapse.
+const VAULT_SCOPE_SELECTED_EDGE_CLASS =
+ 'data-[spacing=0]:data-[variant=outline]:aria-[checked=true]:border-l data-[spacing=0]:data-[variant=outline]:data-[state=on]:border-l'
+
+const VAULT_SCOPE_TOGGLE_ITEM_CLASS = `h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground ${VAULT_SCOPE_SELECTED_EDGE_CLASS} @max-[300px]/ai-vault:px-1.5`
export function VaultGroupHeader({
group,
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts
index 72be0b95b12..dfeedfcfb00 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts
@@ -1,9 +1,17 @@
import { describe, expect, it } from 'vitest'
import {
+ DEFAULT_AI_VAULT_SCOPE,
+ getRestorableAiVaultScope,
normalizeAiVaultScopeForContext,
- shouldRestoreAiVaultProjectScope
+ shouldRestoreDefaultAiVaultScope
} from './ai-vault-scope-state'
+describe('DEFAULT_AI_VAULT_SCOPE', () => {
+ it('defaults the session history scope to workspace', () => {
+ expect(DEFAULT_AI_VAULT_SCOPE).toBe('workspace')
+ })
+})
+
describe('normalizeAiVaultScopeForContext', () => {
it('falls back from project to all when no active project is available', () => {
expect(
@@ -44,24 +52,97 @@ describe('normalizeAiVaultScopeForContext', () => {
})
})
-describe('shouldRestoreAiVaultProjectScope', () => {
- it('restores project after automatic fallback when a project becomes available', () => {
+describe('shouldRestoreDefaultAiVaultScope', () => {
+ it('restores workspace after automatic fallback when a workspace becomes available', () => {
expect(
- shouldRestoreAiVaultProjectScope({
+ shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
userChangedScope: false
})
).toBe(true)
})
- it('does not restore project after the user manually changed scope', () => {
+ it('does not restore workspace while the workspace is unavailable', () => {
expect(
- shouldRestoreAiVaultProjectScope({
+ shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
+ activeWorktreePath: null,
+ userChangedScope: false
+ })
+ ).toBe(false)
+ })
+
+ it('does not restore workspace after the user manually chose project or all', () => {
+ expect(
+ shouldRestoreDefaultAiVaultScope({
+ scope: 'all',
+ activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
userChangedScope: true
})
).toBe(false)
})
+
+ it('can restore a project default only when a project becomes available', () => {
+ expect(
+ shouldRestoreDefaultAiVaultScope({
+ scope: 'all',
+ activeProjectKey: null,
+ activeWorktreePath: '/repo',
+ userChangedScope: false,
+ defaultScope: 'project'
+ })
+ ).toBe(false)
+
+ expect(
+ shouldRestoreDefaultAiVaultScope({
+ scope: 'all',
+ activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
+ userChangedScope: false,
+ defaultScope: 'project'
+ })
+ ).toBe(true)
+ })
+})
+
+describe('getRestorableAiVaultScope', () => {
+ it('restores the workspace preference after automatic fallback', () => {
+ expect(
+ getRestorableAiVaultScope({
+ scope: 'all',
+ activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
+ preferredScope: 'workspace',
+ userChangedScope: false
+ })
+ ).toBe('workspace')
+ })
+
+ it('restores a manual project preference when a project becomes available again', () => {
+ expect(
+ getRestorableAiVaultScope({
+ scope: 'all',
+ activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
+ preferredScope: 'project',
+ userChangedScope: true
+ })
+ ).toBe('project')
+ })
+
+ it('keeps a manual all preference sticky', () => {
+ expect(
+ getRestorableAiVaultScope({
+ scope: 'all',
+ activeProjectKey: 'project:orca',
+ activeWorktreePath: '/repo',
+ preferredScope: 'all',
+ userChangedScope: true
+ })
+ ).toBeNull()
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts
index 5f564cdcdcd..5890fcf97e7 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts
@@ -1,5 +1,7 @@
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
+export const DEFAULT_AI_VAULT_SCOPE: AiVaultScope = 'workspace'
+
export function normalizeAiVaultScopeForContext(args: {
scope: AiVaultScope
activeProjectKey: string | null
@@ -14,10 +16,65 @@ export function normalizeAiVaultScopeForContext(args: {
return args.scope
}
-export function shouldRestoreAiVaultProjectScope(args: {
+function isAiVaultScopeAvailable(args: {
scope: AiVaultScope
activeProjectKey: string | null
- userChangedScope: boolean
+ activeWorktreePath: string | null
}): boolean {
- return Boolean(args.activeProjectKey && args.scope === 'all' && !args.userChangedScope)
+ return normalizeAiVaultScopeForContext(args) === args.scope
+}
+
+export function shouldRestoreDefaultAiVaultScope(args: {
+ scope: AiVaultScope
+ activeProjectKey: string | null
+ activeWorktreePath: string | null
+ userChangedScope: boolean
+ defaultScope?: AiVaultScope
+}): boolean {
+ const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
+
+ return (
+ args.scope === 'all' &&
+ !args.userChangedScope &&
+ isAiVaultScopeAvailable({
+ scope: defaultScope,
+ activeProjectKey: args.activeProjectKey,
+ activeWorktreePath: args.activeWorktreePath
+ })
+ )
+}
+
+export function getRestorableAiVaultScope(args: {
+ scope: AiVaultScope
+ activeProjectKey: string | null
+ activeWorktreePath: string | null
+ preferredScope: AiVaultScope
+ userChangedScope: boolean
+ defaultScope?: AiVaultScope
+}): AiVaultScope | null {
+ const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
+
+ if (args.preferredScope === defaultScope) {
+ return shouldRestoreDefaultAiVaultScope({
+ scope: args.scope,
+ activeProjectKey: args.activeProjectKey,
+ activeWorktreePath: args.activeWorktreePath,
+ userChangedScope: args.userChangedScope,
+ defaultScope
+ })
+ ? defaultScope
+ : null
+ }
+
+ if (args.scope !== 'all' || args.preferredScope === 'all') {
+ return null
+ }
+
+ return isAiVaultScopeAvailable({
+ scope: args.preferredScope,
+ activeProjectKey: args.activeProjectKey,
+ activeWorktreePath: args.activeWorktreePath
+ })
+ ? args.preferredScope
+ : null
}
From 5d8617bc7134d517b71b3c54808d0b1fd5a8f179 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Tue, 23 Jun 2026 17:07:28 -0700
Subject: [PATCH 11/18] Fix mobile workspace parity (#6207)
Co-authored-by: Orca
---
mobile/app/h/[hostId]/index.tsx | 40 ++--
mobile/scripts/mobile-lag-scenario.ts | 2 +
mobile/src/components/WorktreeListRow.tsx | 71 +++++-
mobile/src/terminal/terminal-webview-html.ts | 13 +-
.../terminal/terminal-webview-reflow.test.ts | 7 +
.../worktree/mobile-workspace-lineage.test.ts | 72 ++++++
.../src/worktree/mobile-workspace-lineage.ts | 74 +++++++
.../src/worktree/mobile-workspace-statuses.ts | 42 ++++
mobile/src/worktree/use-workspace-sections.ts | 23 +-
.../worktree/workspace-list-sections.test.ts | 194 ++++++++++++++++-
.../src/worktree/workspace-list-sections.ts | 206 ++++++++----------
mobile/src/worktree/workspace-list-types.ts | 49 +++++
.../worktree/workspace-pr-status-groups.ts | 31 +++
.../worktree/workspace-view-settings.test.ts | 16 +-
.../src/worktree/workspace-view-settings.ts | 13 +-
.../worktree/worktree-list-snapshot.test.ts | 32 +++
mobile/src/worktree/worktree-list-snapshot.ts | 20 ++
src/main/runtime/orca-runtime.test.ts | 2 +
src/main/runtime/orca-runtime.ts | 10 +
src/main/runtime/runtime-rpc.test.ts | 1 +
src/shared/runtime-types.ts | 3 +
21 files changed, 771 insertions(+), 150 deletions(-)
create mode 100644 mobile/src/worktree/mobile-workspace-lineage.test.ts
create mode 100644 mobile/src/worktree/mobile-workspace-lineage.ts
create mode 100644 mobile/src/worktree/mobile-workspace-statuses.ts
create mode 100644 mobile/src/worktree/workspace-list-types.ts
create mode 100644 mobile/src/worktree/workspace-pr-status-groups.ts
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 1262ecd8e2f..c93ef44c03a 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -83,6 +83,7 @@ import {
type Worktree
} from '../../../src/worktree/workspace-list-sections'
import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections'
+import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
import { repoColor } from '../../../src/worktree/repo-color'
import {
@@ -90,6 +91,8 @@ import {
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../../../src/worktree/workspace-list-picker-options'
import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
+import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
+import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
@@ -168,6 +171,9 @@ export function HostScreen({
hideDefaultBranch: false
})
const [groupMode, setGroupMode] = useState('repo')
+ const [workspaceStatuses, setWorkspaceStatuses] = useState(
+ DEFAULT_MOBILE_WORKSPACE_STATUSES
+ )
// displayName → repo id, populated from repo.list. The filter model keys on
// repo ids (desktop's PersistedUIState), but the section headers/rows key on
// displayName, so we bridge the two here.
@@ -198,6 +204,7 @@ export function HostScreen({
hideDefaultBranch: false,
filterRepoIds: [],
collapsedGroups: [],
+ workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES,
workspaceHostScope: undefined,
visibleWorkspaceHostIds: undefined
})
@@ -210,10 +217,11 @@ export function HostScreen({
hideDefaultBranch: filters.hideDefaultBranch,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
+ workspaceStatuses,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
}
- }, [groupMode, sortMode, filters, collapsedGroups])
+ }, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
// states and the snapshot ref in one shot.
@@ -221,6 +229,7 @@ export function HostScreen({
viewStateRef.current = next
setGroupMode(next.groupMode)
setSortMode(next.sortMode)
+ setWorkspaceStatuses(next.workspaceStatuses)
setCollapsedGroups(new Set(next.collapsedGroups))
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
@@ -787,12 +796,12 @@ export function HostScreen({
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
const toggleCollapsed = useCallback(
- (title: string) => {
+ (key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
- if (next.has(title)) {
- next.delete(title)
+ if (next.has(key)) {
+ next.delete(key)
} else {
- next.add(title)
+ next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
@@ -810,7 +819,8 @@ export function HostScreen({
repoColorsByName,
collapsedGroups,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
- visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
+ visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds,
+ workspaceStatuses
})
const existingWorktreePaths = useMemo(() => worktrees.map((w) => w.path), [worktrees])
@@ -1160,12 +1170,11 @@ export function HostScreen({
)}
- {/* Worktree list */}
{sections.length > 0 && (
w.worktreeId}
+ keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
stickySectionHeadersEnabled={false}
onScrollToIndexFailed={onScrollToIndexFailed}
// Why: edge-to-edge — the list scrolls under the system nav bar
@@ -1181,17 +1190,14 @@ export function HostScreen({
if (!section.title) {
return null
}
- const isCollapsed = collapsedGroups.has(section.title)
- const rawSection = rawSections.find((s) => s.title === section.title)
+ const isCollapsed = collapsedGroups.has(section.key)
+ const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon = groupMode === 'repo' ? repoIconsByName.get(section.title) : null
return (
- toggleCollapsed(section.title)}
- >
+ toggleCollapsed(section.key)}>
{isCollapsed ? (
) : (
@@ -1226,12 +1232,14 @@ export function HostScreen({
hideRepo={groupMode === 'repo'}
onPress={openWorktreeSession}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
+ onToggleLineage={(row) =>
+ toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
+ }
/>
)}
/>
)}
- {/* Sort picker modal */}
setShowSortPicker(false)}
/>
- {/* Group picker modal */}
setShowGroupPicker(false)}
/>
- {/* Filter modal — matches desktop's Status + Repositories dropdown */}
setShowFilterModal(false)}>
Filter
diff --git a/mobile/scripts/mobile-lag-scenario.ts b/mobile/scripts/mobile-lag-scenario.ts
index cd0aa2feedf..261596b9e11 100644
--- a/mobile/scripts/mobile-lag-scenario.ts
+++ b/mobile/scripts/mobile-lag-scenario.ts
@@ -68,6 +68,8 @@ export function createMockWorktrees(
parentWorktreeId: null,
childWorktreeIds: [],
displayName: name,
+ workspaceStatus: index % 9 === 0 ? 'in-review' : 'in-progress',
+ sortOrder: now - index * 1000,
linkedIssue: index % 7 === 0 ? 200 + index : null,
linkedPR,
linkedLinearIssue: index % 13 === 0 ? `ORC-${index + 1}` : null,
diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx
index aa4d50fdc8d..1bb7835cfa7 100644
--- a/mobile/src/components/WorktreeListRow.tsx
+++ b/mobile/src/components/WorktreeListRow.tsx
@@ -1,9 +1,9 @@
-import { Bell, GitPullRequest } from 'lucide-react-native'
+import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
-import { colors, spacing, typography } from '../theme/mobile-theme'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { AgentSpinner } from './AgentSpinner'
import { MobileRepoIcon } from './MobileRepoIcon'
import { WorktreeAgentList } from './WorktreeAgentList'
@@ -34,6 +34,9 @@ export type WorktreeListRowItem = {
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
+ lineageDepth?: number
+ lineageChildCount?: number
+ lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
}
@@ -51,6 +54,7 @@ type Props = {
status: WorktreeRollupStatus
onPress: (item: T) => void
onLongPress?: (item: T) => void
+ onToggleLineage?: (item: T) => void
}
export function WorktreeListRow({
@@ -62,16 +66,20 @@ export function WorktreeListRow({
hideRepo = false,
status,
onPress,
- onLongPress
+ onLongPress,
+ onToggleLineage
}: Props) {
const isFolderWorkspace = item.workspaceKind === 'folder-workspace'
const folderMeta = item.comment?.trim() || item.path || 'Folder'
const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch)
+ const lineageDepth = Math.max(0, item.lineageDepth ?? 0)
+ const lineageChildCount = item.lineageChildCount ?? 0
return (
[
styles.worktreeRow,
+ lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 },
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
@@ -133,6 +141,12 @@ export function WorktreeListRow({
/>
+ {lineageDepth > 0 && (
+
+
+ Child
+
+ )}
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
@@ -153,6 +167,25 @@ export function WorktreeListRow({
{item.agents && item.agents.length > 0 ? (
) : null}
+ {lineageChildCount > 0 && onToggleLineage ? (
+ {
+ event.stopPropagation()
+ onToggleLineage(item)
+ }}
+ >
+ {item.lineageCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {lineageChildCount} {lineageChildCount === 1 ? 'child' : 'children'}
+
+
+ ) : null}
{item.liveTerminalCount > 0 && (
@@ -167,7 +200,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm + 2,
- paddingHorizontal: spacing.lg,
+ paddingLeft: spacing.lg,
+ paddingRight: spacing.lg,
// Reserve the active accent bar width so active/inactive rows align.
borderLeftWidth: 2,
borderLeftColor: 'transparent'
@@ -254,6 +288,35 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
flexShrink: 1
},
+ childBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 3,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: 5,
+ paddingVertical: 1,
+ borderRadius: 4
+ },
+ childBadgeText: {
+ fontSize: 10,
+ color: colors.textMuted
+ },
+ lineageToggle: {
+ alignSelf: 'flex-start',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ marginTop: spacing.xs,
+ backgroundColor: colors.bgRaised,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: radii.button
+ },
+ lineageToggleText: {
+ fontSize: 11,
+ color: colors.textSecondary,
+ fontWeight: '600'
+ },
terminalCount: {
fontSize: typography.metaSize,
color: colors.textMuted,
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index 9f8b886a711..a1672f3f91a 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -224,6 +224,7 @@ export const XTERM_HTML = `
var userScale = 1;
var BASE_FONT_PX = 13;
var MIN_FONT_PX = 6;
+ var MIN_FIT_COLS = 20;
var currentTextScale = 1;
var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])};
var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
@@ -255,7 +256,8 @@ export const XTERM_HTML = `
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW > 0 && cellH > 0) {
- var cols = Math.max(1, Math.floor(window.innerWidth / cellW));
+ var cols = Math.floor(window.innerWidth / cellW);
+ if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
}
@@ -844,6 +846,15 @@ export const XTERM_HTML = `
? containerHeightPx
: window.innerHeight;
var cols = Math.floor(vpWidth / cellWidth);
+ if (cols < MIN_FIT_COLS) {
+ flog('measure-skip-small-width', {
+ vpWidth: vpWidth,
+ cellWidth: cellWidth,
+ cols: cols
+ });
+ notify({ type: 'measure-result', cols: null, rows: null });
+ return;
+ }
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts
index da1d2095b5b..5fb78bb33ec 100644
--- a/mobile/src/terminal/terminal-webview-reflow.test.ts
+++ b/mobile/src/terminal/terminal-webview-reflow.test.ts
@@ -49,6 +49,13 @@ describe('terminal WebView reflow', () => {
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
})
+ it('does not locally resize hidden WebViews to a one-column grid', () => {
+ expect(htmlSource).toContain('var MIN_FIT_COLS = 20;')
+ expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;')
+ expect(htmlSource).toContain("flog('measure-skip-small-width'")
+ expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });")
+ })
+
// Why: the raw-source assertions above pass even if the reflow module is
// dropped from the XTERM_HTML concatenation (a broken/removed import or an
// emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template
diff --git a/mobile/src/worktree/mobile-workspace-lineage.test.ts b/mobile/src/worktree/mobile-workspace-lineage.test.ts
new file mode 100644
index 00000000000..f32219da431
--- /dev/null
+++ b/mobile/src/worktree/mobile-workspace-lineage.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
+import type { Worktree } from './workspace-list-sections'
+
+function worktree(overrides: Partial = {}): Worktree {
+ return {
+ workspaceKind: 'git',
+ worktreeId: 'worktree',
+ repoId: 'repo-1',
+ repo: 'orca',
+ branch: 'feature/mobile-parity',
+ displayName: 'worktree',
+ path: '/tmp/worktree',
+ liveTerminalCount: 0,
+ hasAttachedPty: false,
+ preview: '',
+ unread: false,
+ isPinned: false,
+ linkedPR: null,
+ status: 'inactive',
+ agents: [],
+ ...overrides
+ }
+}
+
+describe('applyMobileWorkspaceLineage', () => {
+ it('places visible children directly under their parent', () => {
+ const parent = worktree({ worktreeId: 'parent', displayName: 'parent' })
+ const child = worktree({
+ worktreeId: 'child',
+ displayName: 'child',
+ parentWorktreeId: 'parent'
+ })
+
+ const rows = applyMobileWorkspaceLineage([child, parent])
+
+ expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child'])
+ expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1])
+ expect(rows[0]?.lineageChildCount).toBe(1)
+ })
+
+ it('supports nested lineage chains', () => {
+ const parent = worktree({ worktreeId: 'parent' })
+ const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
+ const grandchild = worktree({ worktreeId: 'grandchild', parentWorktreeId: 'child' })
+
+ const rows = applyMobileWorkspaceLineage([grandchild, child, parent])
+
+ expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child', 'grandchild'])
+ expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1, 2])
+ })
+
+ it('collapses descendants under lineage parents', () => {
+ const parent = worktree({ worktreeId: 'parent' })
+ const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
+
+ const rows = applyMobileWorkspaceLineage([child, parent], new Set(['workspace-lineage:parent']))
+
+ expect(rows.map((row) => row.worktreeId)).toEqual(['parent'])
+ expect(rows[0]?.lineageChildCount).toBe(1)
+ expect(rows[0]?.lineageCollapsed).toBe(true)
+ })
+
+ it('keeps rows visible when lineage is cyclic', () => {
+ const first = worktree({ worktreeId: 'first', parentWorktreeId: 'second' })
+ const second = worktree({ worktreeId: 'second', parentWorktreeId: 'first' })
+
+ const rows = applyMobileWorkspaceLineage([first, second])
+
+ expect(rows.map((row) => row.worktreeId).sort()).toEqual(['first', 'second'])
+ })
+})
diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts
new file mode 100644
index 00000000000..b28b7395928
--- /dev/null
+++ b/mobile/src/worktree/mobile-workspace-lineage.ts
@@ -0,0 +1,74 @@
+import type { Worktree } from './workspace-list-types'
+
+export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string {
+ return `workspace-lineage:${encodeURIComponent(worktreeId)}`
+}
+
+export function applyMobileWorkspaceLineage(
+ worktrees: readonly Worktree[],
+ collapsedGroups: ReadonlySet = new Set()
+): Worktree[] {
+ const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId))
+ const childrenByParentId = new Map()
+ const childIds = new Set()
+
+ for (const worktree of worktrees) {
+ const parentId = worktree.parentWorktreeId
+ if (!parentId || parentId === worktree.worktreeId || !visibleIds.has(parentId)) {
+ continue
+ }
+ childIds.add(worktree.worktreeId)
+ const children = childrenByParentId.get(parentId) ?? []
+ children.push(worktree)
+ childrenByParentId.set(parentId, children)
+ }
+
+ const result: Worktree[] = []
+ const emitted = new Set()
+ const markDescendantsEmitted = (worktree: Worktree): void => {
+ for (const child of childrenByParentId.get(worktree.worktreeId) ?? []) {
+ if (!emitted.has(child.worktreeId)) {
+ emitted.add(child.worktreeId)
+ markDescendantsEmitted(child)
+ }
+ }
+ }
+ const emit = (worktree: Worktree, depth: number, isLastChild: boolean): void => {
+ if (emitted.has(worktree.worktreeId)) {
+ return
+ }
+ const children = childrenByParentId.get(worktree.worktreeId) ?? []
+ const lineageCollapsed =
+ children.length > 0 &&
+ collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree.worktreeId))
+ emitted.add(worktree.worktreeId)
+ result.push({
+ ...worktree,
+ lineageDepth: depth,
+ lineageChildCount: children.length,
+ lineageCollapsed,
+ isLastLineageChild: isLastChild
+ })
+ if (lineageCollapsed) {
+ markDescendantsEmitted(worktree)
+ return
+ }
+ children.forEach((child, index) => {
+ emit(child, depth + 1, index === children.length - 1)
+ })
+ }
+
+ const roots = worktrees.filter((worktree) => !childIds.has(worktree.worktreeId))
+ roots.forEach((worktree, index) => {
+ emit(worktree, 0, index === roots.length - 1)
+ })
+
+ for (const worktree of worktrees) {
+ if (!emitted.has(worktree.worktreeId)) {
+ // Why: malformed cyclic lineage should not hide every participant.
+ emit(worktree, 0, true)
+ }
+ }
+
+ return result
+}
diff --git a/mobile/src/worktree/mobile-workspace-statuses.ts b/mobile/src/worktree/mobile-workspace-statuses.ts
new file mode 100644
index 00000000000..2c1322d8258
--- /dev/null
+++ b/mobile/src/worktree/mobile-workspace-statuses.ts
@@ -0,0 +1,42 @@
+import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
+
+export const DEFAULT_MOBILE_WORKSPACE_STATUS_ID = 'in-progress'
+
+export const DEFAULT_MOBILE_WORKSPACE_STATUSES = [
+ { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' },
+ { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
+ {
+ id: DEFAULT_MOBILE_WORKSPACE_STATUS_ID,
+ label: 'In progress',
+ color: 'conductor-progress',
+ icon: 'conductor-progress'
+ },
+ { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
+] as const satisfies readonly WorkspaceStatusDefinition[]
+
+export function coerceMobileWorkspaceStatuses(
+ statuses: readonly WorkspaceStatusDefinition[]
+): readonly WorkspaceStatusDefinition[] {
+ return statuses.length > 0 ? statuses : DEFAULT_MOBILE_WORKSPACE_STATUSES
+}
+
+export function getMobileWorkspaceStatus(
+ worktree: { workspaceStatus?: string | null },
+ statuses: readonly WorkspaceStatusDefinition[]
+): string {
+ const availableStatuses = coerceMobileWorkspaceStatuses(statuses)
+ if (
+ worktree.workspaceStatus &&
+ availableStatuses.some((status) => status.id === worktree.workspaceStatus)
+ ) {
+ return worktree.workspaceStatus
+ }
+ if (availableStatuses.some((status) => status.id === DEFAULT_MOBILE_WORKSPACE_STATUS_ID)) {
+ return DEFAULT_MOBILE_WORKSPACE_STATUS_ID
+ }
+ return availableStatuses[0]?.id ?? DEFAULT_MOBILE_WORKSPACE_STATUS_ID
+}
+
+export function getMobileWorkspaceStatusGroupKey(status: string): string {
+ return `workspace-status:${encodeURIComponent(status)}`
+}
diff --git a/mobile/src/worktree/use-workspace-sections.ts b/mobile/src/worktree/use-workspace-sections.ts
index 367fd3ec4eb..3337916a920 100644
--- a/mobile/src/worktree/use-workspace-sections.ts
+++ b/mobile/src/worktree/use-workspace-sections.ts
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
+import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
import {
buildSections,
@@ -123,6 +124,7 @@ export function useWorkspaceSections(args: {
collapsedGroups: Set
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
}): {
sections: Section[]
rawSections: Section[]
@@ -141,7 +143,8 @@ export function useWorkspaceSections(args: {
repoColorsByName,
collapsedGroups,
workspaceHostScope,
- visibleWorkspaceHostIds
+ visibleWorkspaceHostIds,
+ workspaceStatuses
} = args
const visibleRepoIdsByName = useMemo(() => {
@@ -189,16 +192,28 @@ export function useWorkspaceSections(args: {
search,
groupMode,
pinnedIds,
- visibleRepoIdsByName
+ visibleRepoIdsByName,
+ workspaceStatuses,
+ collapsedGroups
),
- [hostScopedWorktrees, sortMode, filters, search, groupMode, pinnedIds, visibleRepoIdsByName]
+ [
+ hostScopedWorktrees,
+ sortMode,
+ filters,
+ search,
+ groupMode,
+ pinnedIds,
+ visibleRepoIdsByName,
+ workspaceStatuses,
+ collapsedGroups
+ ]
)
const sections = useMemo(
() =>
rawSections.map((s) => ({
...s,
- data: collapsedGroups.has(s.title) ? [] : s.data
+ data: collapsedGroups.has(s.key) ? [] : s.data
})),
[rawSections, collapsedGroups]
)
diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts
index bc5d3db8a4f..e6de6d8f466 100644
--- a/mobile/src/worktree/workspace-list-sections.test.ts
+++ b/mobile/src/worktree/workspace-list-sections.test.ts
@@ -2,6 +2,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Worktree } from './workspace-list-sections'
import { buildSections, filterWorktrees, getWorktreeStatus } from './workspace-list-sections'
+import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
function worktree(overrides: Partial = {}): Worktree {
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
@@ -25,6 +26,22 @@ function worktree(overrides: Partial = {}): Worktree {
}
}
+function withoutSectionListKeys(sections: ReturnType) {
+ return sections.map((section) => ({
+ ...section,
+ data: section.data.map(
+ ({
+ sectionListKey: _sectionListKey,
+ lineageDepth: _lineageDepth,
+ lineageChildCount: _lineageChildCount,
+ lineageCollapsed: _lineageCollapsed,
+ isLastLineageChild: _isLastLineageChild,
+ ...item
+ }) => item
+ )
+ }))
+}
+
describe('filterWorktrees', () => {
it('hides archived worktrees', () => {
const visible = worktree({ worktreeId: 'visible' })
@@ -128,6 +145,29 @@ describe('getWorktreeStatus', () => {
})
describe('buildSections', () => {
+ it('uses desktop manual order ranks in Manual sort mode', () => {
+ const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 })
+ const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 })
+ const fallback = worktree({ worktreeId: 'fallback', displayName: 'fallback', sortOrder: 20 })
+
+ const sections = buildSections(
+ [low, high, fallback],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ DEFAULT_MOBILE_WORKSPACE_STATUSES
+ )
+
+ expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([
+ 'high',
+ 'fallback',
+ 'low'
+ ])
+ })
+
it('renders empty repo sections from repo placeholders in repo grouping', () => {
const sections = buildSections(
[worktree({ repoId: 'repo-1', repo: 'orca' })],
@@ -142,9 +182,13 @@ describe('buildSections', () => {
])
)
- expect(sections).toEqual([
- { title: 'orca', data: [worktree({ repoId: 'repo-1', repo: 'orca' })] },
- { title: 'zoom-img', data: [] }
+ expect(withoutSectionListKeys(sections)).toEqual([
+ {
+ key: 'repo:repo-1',
+ title: 'orca',
+ data: [worktree({ repoId: 'repo-1', repo: 'orca' })]
+ },
+ { key: 'repo:repo-missing', title: 'zoom-img', data: [] }
])
})
@@ -159,7 +203,7 @@ describe('buildSections', () => {
new Map([['zoom-img', 'repo-missing']])
)
- expect(sections).toEqual([])
+ expect(withoutSectionListKeys(sections)).toEqual([])
})
it('applies repo filters and search to empty repo sections', () => {
@@ -181,7 +225,9 @@ describe('buildSections', () => {
])
)
- expect(sections).toEqual([{ title: 'zoom-img', data: [] }])
+ expect(withoutSectionListKeys(sections)).toEqual([
+ { key: 'repo:repo-matching', title: 'zoom-img', data: [] }
+ ])
})
it('does not add an empty repo section when all of its worktrees are filtered out', () => {
@@ -203,6 +249,142 @@ describe('buildSections', () => {
])
)
- expect(sections).toEqual([{ title: 'empty-repo', data: [] }])
+ expect(withoutSectionListKeys(sections)).toEqual([
+ { key: 'repo:repo-empty', title: 'empty-repo', data: [] }
+ ])
+ })
+
+ it('groups by desktop workspace status labels and order', () => {
+ const review = worktree({
+ worktreeId: 'review',
+ workspaceStatus: 'in-review',
+ status: 'active'
+ })
+ const progress = worktree({
+ worktreeId: 'progress',
+ workspaceStatus: 'in-progress',
+ status: 'working'
+ })
+
+ const sections = buildSections(
+ [progress, review],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ DEFAULT_MOBILE_WORKSPACE_STATUSES
+ )
+
+ expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
+ { key: 'workspace-status:in-review', title: 'In review' },
+ { key: 'workspace-status:in-progress', title: 'In progress' }
+ ])
+ })
+
+ it('falls back to the default status catalog when desktop sends none', () => {
+ const progress = worktree({
+ worktreeId: 'progress',
+ workspaceStatus: 'in-progress',
+ status: 'working'
+ })
+
+ const sections = buildSections(
+ [progress],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ []
+ )
+
+ expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
+ { key: 'workspace-status:in-progress', title: 'In progress' }
+ ])
+ expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress'])
+ })
+
+ it('does not duplicate pinned worktrees in their canonical status group', () => {
+ const pinned = worktree({
+ worktreeId: 'pinned',
+ workspaceStatus: 'in-progress',
+ isPinned: true
+ })
+
+ const sections = buildSections(
+ [pinned],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ DEFAULT_MOBILE_WORKSPACE_STATUSES
+ )
+
+ expect(withoutSectionListKeys(sections)).toEqual([
+ { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }
+ ])
+ })
+
+ it('nests child workspaces under visible parents in grouped sections', () => {
+ const parent = worktree({
+ worktreeId: 'parent',
+ displayName: 'parent',
+ workspaceStatus: 'in-progress'
+ })
+ const child = worktree({
+ worktreeId: 'child',
+ displayName: 'child',
+ parentWorktreeId: 'parent',
+ workspaceStatus: 'in-progress'
+ })
+
+ const sections = buildSections(
+ [child, parent],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ DEFAULT_MOBILE_WORKSPACE_STATUSES
+ )
+
+ expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent', 'child'])
+ expect(sections[0]?.data.map((worktree) => worktree.lineageDepth)).toEqual([0, 1])
+ })
+
+ it('collapses child workspaces under lineage parent rows', () => {
+ const parent = worktree({
+ worktreeId: 'parent',
+ displayName: 'parent',
+ workspaceStatus: 'in-progress'
+ })
+ const child = worktree({
+ worktreeId: 'child',
+ displayName: 'child',
+ parentWorktreeId: 'parent',
+ workspaceStatus: 'in-progress'
+ })
+
+ const sections = buildSections(
+ [child, parent],
+ 'manual',
+ { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
+ '',
+ 'workspaceStatus',
+ new Set(),
+ new Map(),
+ DEFAULT_MOBILE_WORKSPACE_STATUSES,
+ new Set(['workspace-lineage:parent'])
+ )
+
+ expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent'])
+ expect(sections[0]?.data[0]?.lineageChildCount).toBe(1)
+ expect(sections[0]?.data[0]?.lineageCollapsed).toBe(true)
})
})
diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts
index 6cd233d98b7..1691659e9f4 100644
--- a/mobile/src/worktree/workspace-list-sections.ts
+++ b/mobile/src/worktree/workspace-list-sections.ts
@@ -1,54 +1,36 @@
-// Pure data transforms for the host workspaces list: status derivation,
-// filtering, sorting, and grouping into SectionList sections. Kept out of the
-// screen component so the screen stays under its line cap and the logic is
-// unit-testable in isolation.
-
-import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
+import {
+ DEFAULT_MOBILE_WORKSPACE_STATUSES,
+ coerceMobileWorkspaceStatuses,
+ getMobileWorkspaceStatus,
+ getMobileWorkspaceStatusGroupKey
+} from './mobile-workspace-statuses'
+import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
+import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups'
+import type { FilterState, Section, Worktree } from './workspace-list-types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
-export type Worktree = {
- workspaceKind?: 'git' | 'folder-workspace'
- worktreeId: string
- repoId: string
- repo: string
- branch: string
- displayName: string
- // Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
- // marine-creature fallback dedupes against the actual filesystem basenames
- // (matching the desktop's collision check), not against displayName which
- // the user may have renamed.
- path: string
- isArchived?: boolean
- isMainWorktree?: boolean
- hasHostSidebarActivity?: boolean
- liveTerminalCount: number
- hasAttachedPty: boolean
- preview: string
- unread: boolean
- lastOutputAt?: number
- isPinned: boolean
- isActive?: boolean
- linkedPR: { number: number; state: string } | null
- linkedIssue?: number | null
- linkedLinearIssue?: string | null
- linkedGitLabMR?: number | null
- linkedGitLabIssue?: number | null
- comment?: string
- status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
- agents?: RuntimeWorktreeAgentRow[]
-}
+export type { FilterState, Section, Worktree } from './workspace-list-types'
-// Desktop's filter model (shared via PersistedUIState): repos selected by id,
-// plus two hide toggles. There is no "active only" — hideSleeping is the
-// inverse intent.
-export type FilterState = {
- filterRepoIds: Set
- hideSleeping: boolean
- hideDefaultBranch: boolean
+function makeSection(
+ key: string,
+ title: string,
+ data: Worktree[],
+ icon?: 'pin',
+ collapsedGroups?: ReadonlySet
+): Section {
+ const rows = collapsedGroups ? applyMobileWorkspaceLineage(data, collapsedGroups) : data
+ return {
+ key,
+ title,
+ ...(icon ? { icon } : {}),
+ data: rows.map((worktree) => ({
+ ...worktree,
+ sectionListKey: `${key}:${worktree.worktreeId}`
+ }))
+ }
}
-export type Section = { title: string; icon?: 'pin'; data: Worktree[] }
-
export function getWorktreeStatus(
w: Worktree
): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
@@ -104,26 +86,28 @@ function isDefaultBranchWorkspace(w: Worktree): boolean {
return branch === 'main' || branch === 'master'
}
-export const WORKSPACE_STATUS_LABELS: Record, string> = {
- permission: 'Needs Permission',
- working: 'Working',
- done: 'Done',
- active: 'Active',
- inactive: 'Inactive'
+function getManualSortRank(worktree: Worktree): number | null {
+ const rank = worktree.manualOrder ?? worktree.sortOrder
+ return typeof rank === 'number' && Number.isFinite(rank) ? rank : null
}
-export const WORKSPACE_STATUS_ORDER: ReturnType[] = [
- 'permission',
- 'working',
- 'done',
- 'active',
- 'inactive'
-]
-
export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Worktree[] {
- // 'manual' keeps the server (worktree.ps) order untouched.
if (mode === 'manual') {
- return worktrees
+ return [...worktrees].sort((a, b) => {
+ const aRank = getManualSortRank(a)
+ const bRank = getManualSortRank(b)
+ if (aRank !== null && bRank !== null && aRank !== bRank) {
+ // Why: desktop assigns higher sort/manual ranks to earlier list positions.
+ return bRank - aRank
+ }
+ if (aRank !== null && bRank === null) {
+ return -1
+ }
+ if (aRank === null && bRank !== null) {
+ return 1
+ }
+ return 0
+ })
}
return [...worktrees].sort((a, b) => {
if (mode === 'name') {
@@ -136,7 +120,6 @@ export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Work
const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
}
- // 'smart' — attention-first
if (a.unread !== b.unread) {
return a.unread ? -1 : 1
}
@@ -180,36 +163,6 @@ export function filterWorktrees(
return result
}
-// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
-// no PR/draft/unknown → "In Progress", open → "In Review", merged → "Done", closed → "Closed"
-type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
-
-const PR_GROUP_LABELS: Record = {
- done: 'Done',
- 'in-review': 'In Review',
- 'in-progress': 'In Progress',
- closed: 'Closed'
-}
-
-const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
-
-function getPRGroupKey(w: Worktree): PRGroupKey {
- if (!w.linkedPR) {
- return 'in-progress'
- }
- const s = w.linkedPR.state.toLowerCase()
- if (s === 'merged') {
- return 'done'
- }
- if (s === 'closed') {
- return 'closed'
- }
- if (s === 'draft') {
- return 'in-progress'
- }
- return 'in-review'
-}
-
export function isWorktreePinned(w: Worktree, localPins: Set): boolean {
return w.isPinned || localPins.has(w.worktreeId)
}
@@ -221,33 +174,44 @@ export function buildSections(
search: string,
groupMode: MobileGroupMode,
pinnedIds: Set,
- repoIdsByName: ReadonlyMap = new Map()
+ repoIdsByName: ReadonlyMap = new Map(),
+ workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES,
+ collapsedGroups: ReadonlySet = new Set()
): Section[] {
const filtered = filterWorktrees(worktrees, filters, search)
const sorted = sortWorktrees(filtered, sortMode)
const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds))
const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds))
- const active = unpinned.filter(isWorktreeActive)
- const inactive = unpinned.filter((w) => !isWorktreeActive(w))
+ // Why: mobile shows pinned workspaces once in the pinned section; duplicating
+ // them in status/project sections makes the phone list harder to scan.
+ const canonicalGroupWorktrees = unpinned
+ const active = canonicalGroupWorktrees.filter(isWorktreeActive)
+ const inactive = canonicalGroupWorktrees.filter((w) => !isWorktreeActive(w))
const sections: Section[] = []
if (pinned.length > 0) {
- sections.push({ title: 'Pinned', icon: 'pin', data: pinned })
+ sections.push(makeSection('pinned', 'Pinned', pinned, 'pin'))
}
if (groupMode === 'none') {
if (active.length > 0) {
- // Why: without explicit grouping, mobile's primary workflow is jumping
- // back into running sessions before browsing the full worktree archive.
- sections.push({ title: 'Active', data: active })
+ sections.push(makeSection('all-active', 'Active', active, undefined, collapsedGroups))
}
if (inactive.length > 0) {
- sections.push({ title: pinned.length > 0 || active.length > 0 ? 'All' : '', data: inactive })
+ sections.push(
+ makeSection(
+ 'all',
+ pinned.length > 0 || active.length > 0 ? 'All' : '',
+ inactive,
+ undefined,
+ collapsedGroups
+ )
+ )
}
} else if (groupMode === 'repo') {
const byRepo = new Map()
- for (const w of unpinned) {
+ for (const w of canonicalGroupWorktrees) {
const key = w.repo || 'Unknown'
const list = byRepo.get(key)
if (list) {
@@ -273,12 +237,14 @@ export function buildSections(
}
}
for (const [repo, items] of byRepo) {
- sections.push({ title: repo, data: items })
+ const key = `repo:${repoIdsByName.get(repo) ?? repo}`
+ sections.push(makeSection(key, repo, items, undefined, collapsedGroups))
}
} else if (groupMode === 'workspaceStatus') {
- const byStatus = new Map, Worktree[]>()
- for (const w of unpinned) {
- const key = getWorktreeStatus(w)
+ const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses)
+ const byStatus = new Map()
+ for (const w of canonicalGroupWorktrees) {
+ const key = getMobileWorkspaceStatus(w, renderableWorkspaceStatuses)
const list = byStatus.get(key)
if (list) {
list.push(w)
@@ -286,15 +252,23 @@ export function buildSections(
byStatus.set(key, [w])
}
}
- for (const status of WORKSPACE_STATUS_ORDER) {
- const items = byStatus.get(status)
+ for (const status of renderableWorkspaceStatuses) {
+ const items = byStatus.get(status.id)
if (items && items.length > 0) {
- sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items })
+ sections.push(
+ makeSection(
+ getMobileWorkspaceStatusGroupKey(status.id),
+ status.label,
+ items,
+ undefined,
+ collapsedGroups
+ )
+ )
}
}
} else if (groupMode === 'prStatus') {
- const byGroup = new Map()
- for (const w of unpinned) {
+ const byGroup = new Map()
+ for (const w of canonicalGroupWorktrees) {
const key = getPRGroupKey(w)
const list = byGroup.get(key)
if (list) {
@@ -306,7 +280,15 @@ export function buildSections(
for (const groupKey of PR_GROUP_ORDER) {
const items = byGroup.get(groupKey)
if (items && items.length > 0) {
- sections.push({ title: PR_GROUP_LABELS[groupKey], data: items })
+ sections.push(
+ makeSection(
+ `pr:${groupKey}`,
+ PR_GROUP_LABELS[groupKey],
+ items,
+ undefined,
+ collapsedGroups
+ )
+ )
}
}
}
diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts
new file mode 100644
index 00000000000..8418b2753ad
--- /dev/null
+++ b/mobile/src/worktree/workspace-list-types.ts
@@ -0,0 +1,49 @@
+import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
+
+export type Worktree = {
+ sectionListKey?: string
+ workspaceKind?: 'git' | 'folder-workspace'
+ worktreeId: string
+ repoId: string
+ repo: string
+ branch: string
+ displayName: string
+ workspaceStatus?: string
+ sortOrder?: number
+ manualOrder?: number
+ // Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
+ // marine-creature fallback dedupes against filesystem basenames.
+ path: string
+ isArchived?: boolean
+ isMainWorktree?: boolean
+ hasHostSidebarActivity?: boolean
+ parentWorktreeId?: string | null
+ childWorktreeIds?: string[]
+ lineageDepth?: number
+ lineageChildCount?: number
+ lineageCollapsed?: boolean
+ isLastLineageChild?: boolean
+ liveTerminalCount: number
+ hasAttachedPty: boolean
+ preview: string
+ unread: boolean
+ lastOutputAt?: number
+ isPinned: boolean
+ isActive?: boolean
+ linkedPR: { number: number; state: string } | null
+ linkedIssue?: number | null
+ linkedLinearIssue?: string | null
+ linkedGitLabMR?: number | null
+ linkedGitLabIssue?: number | null
+ comment?: string
+ status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
+ agents?: RuntimeWorktreeAgentRow[]
+}
+
+export type FilterState = {
+ filterRepoIds: Set
+ hideSleeping: boolean
+ hideDefaultBranch: boolean
+}
+
+export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] }
diff --git a/mobile/src/worktree/workspace-pr-status-groups.ts b/mobile/src/worktree/workspace-pr-status-groups.ts
new file mode 100644
index 00000000000..998e1d4be1d
--- /dev/null
+++ b/mobile/src/worktree/workspace-pr-status-groups.ts
@@ -0,0 +1,31 @@
+import type { Worktree } from './workspace-list-sections'
+
+// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
+// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
+export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
+
+export const PR_GROUP_LABELS: Record = {
+ done: 'Done',
+ 'in-review': 'In Review',
+ 'in-progress': 'In Progress',
+ closed: 'Closed'
+}
+
+export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
+
+export function getPRGroupKey(w: Worktree): PRGroupKey {
+ if (!w.linkedPR) {
+ return 'in-progress'
+ }
+ const s = w.linkedPR.state.toLowerCase()
+ if (s === 'merged') {
+ return 'done'
+ }
+ if (s === 'closed') {
+ return 'closed'
+ }
+ if (s === 'draft') {
+ return 'in-progress'
+ }
+ return 'in-review'
+}
diff --git a/mobile/src/worktree/workspace-view-settings.test.ts b/mobile/src/worktree/workspace-view-settings.test.ts
index 08186288d7a..d6cef4448e6 100644
--- a/mobile/src/worktree/workspace-view-settings.test.ts
+++ b/mobile/src/worktree/workspace-view-settings.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
+import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
import {
applyDesktopViewSettings,
groupModeFromDesktop,
@@ -13,7 +14,8 @@ const base: MobileViewState = {
hideSleeping: false,
hideDefaultBranch: false,
filterRepoIds: [],
- collapsedGroups: []
+ collapsedGroups: [],
+ workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
}
describe('group mode mapping', () => {
@@ -47,12 +49,10 @@ describe('applyDesktopViewSettings', () => {
filterRepoIds: ['repo-1']
})
expect(next).toEqual({
+ ...base,
groupMode: 'prStatus',
- sortMode: 'recent', // unchanged (sortBy absent)
hideSleeping: true,
- hideDefaultBranch: false, // unchanged
- filterRepoIds: ['repo-1'],
- collapsedGroups: []
+ filterRepoIds: ['repo-1']
})
})
@@ -60,6 +60,12 @@ describe('applyDesktopViewSettings', () => {
expect(applyDesktopViewSettings(base, {})).toEqual(base)
})
+ it('keeps renderable workspace statuses when desktop sends an empty catalog', () => {
+ const next = applyDesktopViewSettings(base, { workspaceStatuses: [] })
+
+ expect(next.workspaceStatuses).toBe(DEFAULT_MOBILE_WORKSPACE_STATUSES)
+ })
+
it('preserves current host visibility when desktop omits host fields', () => {
const current: MobileViewState = {
...base,
diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts
index a15def9ebcc..179fd8becd9 100644
--- a/mobile/src/worktree/workspace-view-settings.ts
+++ b/mobile/src/worktree/workspace-view-settings.ts
@@ -3,6 +3,9 @@
// RPCs). Keeping these settings in the same global store is what lets a grouping
// or filter change on the phone show up on desktop and vice-versa.
+import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
+import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
+
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
@@ -17,6 +20,7 @@ export type WorkspaceViewSettings = {
collapsedGroups?: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
+ workspaceStatuses?: WorkspaceStatusDefinition[]
}
const GROUP_TO_DESKTOP: Record> = {
@@ -62,6 +66,7 @@ export type MobileViewState = {
collapsedGroups: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
}
// Apply a desktop PersistedUIState onto the local view state, leaving any field
@@ -72,13 +77,19 @@ export function applyDesktopViewSettings(
): MobileViewState {
const groupMode = groupModeFromDesktop(settings.groupBy)
const sortMode = sortModeFromDesktop(settings.sortBy)
+ // Why: a partially hydrated desktop settings payload may carry an empty
+ // status catalog; mobile must keep renderable groups instead of hiding rows.
+ const workspaceStatuses = settings.workspaceStatuses
+ ? coerceMobileWorkspaceStatuses(settings.workspaceStatuses)
+ : current.workspaceStatuses
const next: MobileViewState = {
groupMode: groupMode ?? current.groupMode,
sortMode: sortMode ?? current.sortMode,
hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping,
hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch,
filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds,
- collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups
+ collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
+ workspaceStatuses
}
if (settings.workspaceHostScope !== undefined) {
next.workspaceHostScope = settings.workspaceHostScope
diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts
index 56b31c77aca..339d5d1c9a3 100644
--- a/mobile/src/worktree/worktree-list-snapshot.test.ts
+++ b/mobile/src/worktree/worktree-list-snapshot.test.ts
@@ -29,6 +29,7 @@ function worktree(overrides: Partial = {}): Worktree {
repo: 'orca',
branch: 'feature/mobile-lag',
displayName: 'manta',
+ workspaceStatus: 'in-progress',
path: worktreePath,
liveTerminalCount: 1,
hasAttachedPty: true,
@@ -77,6 +78,37 @@ describe('areWorktreeListsEqual', () => {
)
})
+ it('detects workspace status changes', () => {
+ expect(
+ areWorktreeListsEqual(
+ [worktree({ workspaceStatus: 'in-progress' })],
+ [worktree({ workspaceStatus: 'in-review' })]
+ )
+ ).toBe(false)
+ })
+
+ it('detects manual order changes', () => {
+ expect(
+ areWorktreeListsEqual(
+ [worktree({ manualOrder: 10, sortOrder: 1 })],
+ [worktree({ manualOrder: 20, sortOrder: 1 })]
+ )
+ ).toBe(false)
+ })
+
+ it('detects lineage changes', () => {
+ const base = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-a' })
+ const changedParent = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-b' })
+ const changedChildren = worktree({
+ worktreeId: 'child',
+ parentWorktreeId: 'parent-a',
+ childWorktreeIds: ['grandchild']
+ })
+
+ expect(areWorktreeListsEqual([base], [changedParent])).toBe(false)
+ expect(areWorktreeListsEqual([base], [changedChildren])).toBe(false)
+ })
+
it('detects agent status changes', () => {
const first = [worktree({ agents: [agent({ state: 'working' })] })]
const second = [worktree({ agents: [agent({ state: 'waiting' })] })]
diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts
index 57494d496df..c851bda24d9 100644
--- a/mobile/src/worktree/worktree-list-snapshot.ts
+++ b/mobile/src/worktree/worktree-list-snapshot.ts
@@ -26,10 +26,15 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
left.repo === right.repo &&
left.branch === right.branch &&
left.displayName === right.displayName &&
+ (left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) &&
+ (left.sortOrder ?? null) === (right.sortOrder ?? null) &&
+ (left.manualOrder ?? null) === (right.manualOrder ?? null) &&
left.path === right.path &&
(left.isArchived ?? false) === (right.isArchived ?? false) &&
(left.isMainWorktree ?? false) === (right.isMainWorktree ?? false) &&
(left.hasHostSidebarActivity ?? false) === (right.hasHostSidebarActivity ?? false) &&
+ (left.parentWorktreeId ?? null) === (right.parentWorktreeId ?? null) &&
+ areStringArraysEqual(left.childWorktreeIds ?? [], right.childWorktreeIds ?? []) &&
left.liveTerminalCount === right.liveTerminalCount &&
left.hasAttachedPty === right.hasAttachedPty &&
left.preview === right.preview &&
@@ -48,6 +53,21 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
)
}
+function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean {
+ if (left === right) {
+ return true
+ }
+ if (left.length !== right.length) {
+ return false
+ }
+ for (let index = 0; index < left.length; index += 1) {
+ if (left[index] !== right[index]) {
+ return false
+ }
+ }
+ return true
+}
+
function arePullRequestsEqual(left: Worktree['linkedPR'], right: Worktree['linkedPR']): boolean {
if (left === right) {
return true
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 25dfa727a77..46fb3088e08 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -14716,6 +14716,8 @@ describe('OrcaRuntimeService', () => {
parentWorktreeId: null,
childWorktreeIds: [],
displayName: 'foo',
+ workspaceStatus: 'in-progress',
+ sortOrder: 0,
linkedIssue: 123,
linkedPR: null,
linkedLinearIssue: null,
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index bd81e59fcc7..fef93b96e19 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -8518,6 +8518,8 @@ export class OrcaRuntimeService {
linkedPR = { number: meta.linkedPR, state: 'unknown' }
}
summaries.set(worktree.id, {
+ // Why: mobile mirrors desktop workspace grouping/order from persisted
+ // metadata, while older runtimes may not have hydrated every field yet.
workspaceKind: 'git',
worktreeId: worktree.id,
repoId: worktree.repoId,
@@ -8530,6 +8532,9 @@ export class OrcaRuntimeService {
parentWorktreeId: worktree.parentWorktreeId,
childWorktreeIds: worktree.childWorktreeIds,
displayName: worktree.displayName,
+ workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
+ sortOrder: meta?.sortOrder ?? 0,
+ ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
linkedIssue: worktree.linkedIssue,
linkedPR,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
@@ -8558,6 +8563,8 @@ export class OrcaRuntimeService {
}
const worktree = folderWorkspaceToWorktree(folderWorkspace)
summaries.set(worktree.id, {
+ // Why: folder workspaces use the same mobile grouping/order contract as
+ // git worktrees, but legacy records may be missing order metadata.
workspaceKind: 'folder-workspace',
worktreeId: worktree.id,
repoId: worktree.repoId,
@@ -8570,6 +8577,9 @@ export class OrcaRuntimeService {
parentWorktreeId: null,
childWorktreeIds: [],
displayName: worktree.displayName,
+ workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
+ sortOrder: worktree.sortOrder ?? 0,
+ ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
linkedIssue: worktree.linkedIssue ?? null,
linkedPR: null,
linkedLinearIssue: worktree.linkedLinearIssue ?? null,
diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts
index d052ad24d65..964afa701e6 100644
--- a/src/main/runtime/runtime-rpc.test.ts
+++ b/src/main/runtime/runtime-rpc.test.ts
@@ -2763,6 +2763,7 @@ describe('OrcaRuntimeRpcServer', () => {
path: '/tmp/worktree-a',
branch: 'feature/foo',
linkedIssue: 123,
+ sortOrder: 0,
unread: true,
liveTerminalCount: 1,
hasAttachedPty: true,
diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts
index aab99c6e255..51f4bda546a 100644
--- a/src/shared/runtime-types.ts
+++ b/src/shared/runtime-types.ts
@@ -526,6 +526,9 @@ export type RuntimeWorktreePsSummary = {
parentWorktreeId: string | null
childWorktreeIds: string[]
displayName: string
+ workspaceStatus: string
+ sortOrder: number
+ manualOrder?: number
linkedIssue: number | null
linkedPR: { number: number; state: string } | null
linkedLinearIssue: string | null
From e03a1cf7691713aabff81717642b8e972d4a1cb4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 23 Jun 2026 17:22:10 -0700
Subject: [PATCH 12/18] Prevent managed agent hooks from hanging (#6148)
---
src/main/agent-hooks/installer-utils.test.ts | 1 +
src/main/agent-hooks/installer-utils.ts | 23 +
.../agent-hooks/managed-hook-timeout.test.ts | 399 ++++++++++++++++++
src/main/antigravity/hook-service.ts | 8 +-
src/main/claude/hook-settings.ts | 3 +-
src/main/codex/hook-service.test.ts | 38 +-
src/main/codex/hook-service.ts | 33 +-
src/main/command-code/hook-service.ts | 3 +-
src/main/cursor/hook-service.ts | 5 +-
src/main/devin/hook-settings.ts | 3 +-
src/main/droid/hook-service.ts | 3 +-
src/main/gemini/hook-service.ts | 8 +-
src/main/grok/hook-service.ts | 3 +-
src/main/kimi/kimi-hook-config-toml.ts | 11 +-
14 files changed, 519 insertions(+), 22 deletions(-)
create mode 100644 src/main/agent-hooks/managed-hook-timeout.test.ts
diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts
index 43c08542c60..d316c869d9d 100644
--- a/src/main/agent-hooks/installer-utils.test.ts
+++ b/src/main/agent-hooks/installer-utils.test.ts
@@ -397,6 +397,7 @@ describe('buildWindowsAgentHookPostCommand', () => {
expect(command).toContain('[Console]::OutputEncoding=$utf8')
expect(command).toContain('$bodyBytes=$utf8.GetBytes($body)')
expect(command).toContain("-ContentType 'application/json; charset=utf-8'")
+ expect(command).toContain('-TimeoutSec 2')
expect(command).toContain('/hook/codex')
expect(command).not.toContain("'Content-Type'='application/json'")
})
diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts
index a533fb0ed1a..c4c112f8ead 100644
--- a/src/main/agent-hooks/installer-utils.ts
+++ b/src/main/agent-hooks/installer-utils.ts
@@ -37,6 +37,29 @@ export type HooksConfig = {
[key: string]: unknown
}
+// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell
+// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a
+// hook the agent host itself runs in case that transport budget is bypassed.
+// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets
+// coexist by design (#4633).
+export const MANAGED_HOOK_TIMEOUT_SECONDS = 10
+export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000
+
+// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude,
+// Codex, Gemini, Droid, Grok, Command Code, Devin).
+export function buildManagedCommandHook(
+ command: string,
+ timeout = MANAGED_HOOK_TIMEOUT_SECONDS
+): HookCommandConfig {
+ return { type: 'command', command, timeout }
+}
+
+// Direct command definition used by schemas that put `command` on the
+// definition itself (Cursor's documented top-level shape).
+export function buildManagedCommandDefinition(command: string): HookDefinition {
+ return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
+}
+
export function isPlainObject(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts
new file mode 100644
index 00000000000..6ff32c7a21d
--- /dev/null
+++ b/src/main/agent-hooks/managed-hook-timeout.test.ts
@@ -0,0 +1,399 @@
+// Why: one regression fixture proves the managed hook timeout budget across every
+// managed agent (config entries, wrapper curl flags, and a real dead-endpoint
+// shell run) so the cross-agent coverage lives together rather than fragmenting.
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { spawn, spawnSync } from 'child_process'
+import { createServer, type Server, type Socket } from 'net'
+import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import type { SFTPWrapper } from 'ssh2'
+import type * as osModule from 'os'
+
+const { homedirMock } = vi.hoisted(() => ({
+ homedirMock: vi.fn<() => string>()
+}))
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => '/tmp/orca-user-data'
+ }
+}))
+
+vi.mock('os', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ homedir: homedirMock.mockImplementation(actual.homedir)
+ }
+})
+
+import { MANAGED_HOOK_TIMEOUT_MILLISECONDS, MANAGED_HOOK_TIMEOUT_SECONDS } from './installer-utils'
+import { CodexHookService } from '../codex/hook-service'
+import { CursorHookService } from '../cursor/hook-service'
+import { CommandCodeHookService } from '../command-code/hook-service'
+import { GeminiHookService } from '../gemini/hook-service'
+import { AntigravityHookService } from '../antigravity/hook-service'
+import { ClaudeHookService } from '../claude/hook-service'
+import { GrokHookService } from '../grok/hook-service'
+import { CopilotHookService } from '../copilot/hook-service'
+import { DevinHookService } from '../devin/hook-service'
+import { DroidHookService } from '../droid/hook-service'
+import { KimiHookService } from '../kimi/hook-service'
+import { openClaudeHookService } from '../openclaude/hook-service'
+
+type FakeFs = {
+ files: Map
+ dirs: Set
+ modes: Map
+}
+
+function createFakeSftp(initialFiles: Record = {}): {
+ sftp: SFTPWrapper
+ fs: FakeFs
+} {
+ const fs: FakeFs = {
+ files: new Map(Object.entries(initialFiles)),
+ dirs: new Set(['/']),
+ modes: new Map()
+ }
+ const noEntryError = (path: string): { code: number; message: string } => ({
+ code: 2,
+ message: `ENOENT ${path}`
+ })
+
+ const sftp = {
+ readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
+ const v = fs.files.get(path)
+ if (v === undefined) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, v)
+ },
+ writeFile: (
+ path: string,
+ content: string,
+ options: string | { mode?: number },
+ cb: (err: unknown) => void
+ ): void => {
+ fs.files.set(path, content)
+ if (typeof options !== 'string' && options.mode !== undefined) {
+ fs.modes.set(path, options.mode)
+ }
+ cb(null)
+ },
+ rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
+ const v = fs.files.get(src)
+ if (v === undefined) {
+ cb(noEntryError(src))
+ return
+ }
+ fs.files.set(dst, v)
+ fs.files.delete(src)
+ const mode = fs.modes.get(src)
+ if (mode !== undefined) {
+ fs.modes.set(dst, mode)
+ fs.modes.delete(src)
+ }
+ cb(null)
+ },
+ unlink: (path: string, cb: (err: unknown) => void): void => {
+ fs.files.delete(path)
+ fs.modes.delete(path)
+ cb(null)
+ },
+ chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
+ fs.modes.set(path, mode)
+ cb(null)
+ },
+ stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
+ if (!fs.files.has(path)) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, { mode: fs.modes.get(path) ?? 0o100644 })
+ },
+ readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
+ if (fs.dirs.has(path)) {
+ cb(null, [])
+ return
+ }
+ cb(noEntryError(path))
+ },
+ mkdir: (path: string, cb: (err: unknown) => void): void => {
+ fs.dirs.add(path)
+ cb(null)
+ }
+ } as unknown as SFTPWrapper
+ return { sftp, fs }
+}
+
+const REMOTE_HOME = '/home/dev'
+
+// Each managed agent that ships an SSH-compatible JSON/TOML hook config. Amp and
+// Hermes are intentionally excluded: they are plugin systems with no hook config
+// entries, so their transport budgets live in plugin source (see design doc).
+const JSON_INSTALLERS = [
+ {
+ agent: 'claude',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.claude/settings.json`,
+ install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'openclaude',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.openclaude/settings.json`,
+ install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'codex',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.codex/hooks.json`,
+ install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'gemini',
+ timeout: MANAGED_HOOK_TIMEOUT_MILLISECONDS,
+ configPath: `${REMOTE_HOME}/.gemini/settings.json`,
+ install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'antigravity',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.gemini/config/hooks.json`,
+ install: (sftp: SFTPWrapper) => new AntigravityHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'cursor',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.cursor/hooks.json`,
+ install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'command-code',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.commandcode/settings.json`,
+ install: (sftp: SFTPWrapper) => new CommandCodeHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'grok',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.grok/hooks/orca-status.json`,
+ install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'copilot',
+ timeout: 5,
+ configPath: `${REMOTE_HOME}/.copilot/hooks/orca.json`,
+ install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, REMOTE_HOME)
+ },
+ {
+ agent: 'devin',
+ timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
+ configPath: `${REMOTE_HOME}/.config/devin/config.json`,
+ install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, REMOTE_HOME)
+ }
+] as const
+
+const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
+
+// Walk the parsed config and assert every Orca-managed command carrier (a node
+// with a `command`/`bash`/`powershell` string pointing at the managed script
+// dir) has a positive config-level timeout sibling (`timeout` or the
+// provider-specific `timeoutSec`). Returns the count of managed carriers found
+// so callers can assert the scan was not vacuous.
+function countManagedCarriersWithTimeout(node: unknown, expectedTimeout: number): number {
+ if (Array.isArray(node)) {
+ return node.reduce(
+ (sum, child) => sum + countManagedCarriersWithTimeout(child, expectedTimeout),
+ 0
+ )
+ }
+ if (node === null || typeof node !== 'object') {
+ return 0
+ }
+ const record = node as Record
+ let found = 0
+ const carrier = [record.command, record.bash, record.powershell].find(
+ (value): value is string =>
+ typeof value === 'string' && value.includes(MANAGED_HOOKS_DIR_NEEDLE)
+ )
+ if (carrier !== undefined) {
+ const timeout = typeof record.timeout === 'number' ? record.timeout : record.timeoutSec
+ expect(typeof timeout, `managed carrier "${carrier}" is missing a config timeout`).toBe(
+ 'number'
+ )
+ expect(timeout as number).toBe(expectedTimeout)
+ found += 1
+ }
+ for (const value of Object.values(record)) {
+ found += countManagedCarriersWithTimeout(value, expectedTimeout)
+ }
+ return found
+}
+
+describe('managed agent hook timeouts', () => {
+ it('writes a config-level timeout on every managed JSON hook entry', async () => {
+ for (const { agent, configPath, install, timeout } of JSON_INSTALLERS) {
+ const { sftp, fs } = createFakeSftp()
+ const status = await install(sftp)
+ expect(status.state, `${agent} install state`).toBe('installed')
+ const raw = fs.files.get(configPath)
+ expect(raw, `${agent} config written`).toBeDefined()
+ const carriers = countManagedCarriersWithTimeout(JSON.parse(raw!), timeout)
+ expect(
+ carriers,
+ `${agent} should have at least one managed timeout-bearing entry`
+ ).toBeGreaterThan(0)
+ }
+ })
+
+ it('writes a timeout on the managed Kimi TOML hook block', async () => {
+ const { sftp, fs } = createFakeSftp()
+ const status = await new KimiHookService().installRemote(sftp, REMOTE_HOME)
+ expect(status.state).toBe('installed')
+ const config = fs.files.get(`${REMOTE_HOME}/.kimi-code/config.toml`)!
+ // One timeout line per managed [[hooks]] event entry.
+ const timeoutLines = config.match(new RegExp(`timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}`, 'g'))
+ expect(timeoutLines?.length ?? 0).toBeGreaterThan(0)
+ expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh')
+ })
+
+ it('writes a config-level timeout on local-only Droid hooks', () => {
+ const homeDir = mkdtempSync(join(tmpdir(), 'orca-droid-hook-timeout-'))
+ homedirMock.mockReturnValue(homeDir)
+ try {
+ const status = new DroidHookService().install()
+ expect(status.state).toBe('installed')
+ const config = JSON.parse(readFileSync(join(homeDir, '.factory', 'settings.json'), 'utf8'))
+ const carriers = countManagedCarriersWithTimeout(config, MANAGED_HOOK_TIMEOUT_SECONDS)
+ expect(carriers).toBeGreaterThan(0)
+ } finally {
+ homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+ })
+
+ it('bounds every generated POSIX curl wrapper with --connect-timeout and --max-time', async () => {
+ let curlWrappersChecked = 0
+ for (const { agent, install } of JSON_INSTALLERS) {
+ const { sftp, fs } = createFakeSftp()
+ await install(sftp)
+ for (const [path, content] of fs.files) {
+ if (!path.endsWith('.sh')) {
+ continue
+ }
+ expect(content, `${agent} wrapper missing curl transport`).toContain('curl')
+ expect(content, `${agent} wrapper missing --connect-timeout`).toContain('--connect-timeout')
+ expect(content, `${agent} wrapper missing --max-time`).toContain('--max-time')
+ curlWrappersChecked += 1
+ }
+ }
+ const kimi = createFakeSftp()
+ await new KimiHookService().installRemote(kimi.sftp, REMOTE_HOME)
+ const kimiWrapper = kimi.fs.files.get(`${REMOTE_HOME}/.orca/agent-hooks/kimi-hook.sh`)!
+ expect(kimiWrapper, 'kimi wrapper missing --connect-timeout').toContain('--connect-timeout')
+ expect(kimiWrapper, 'kimi wrapper missing --max-time').toContain('--max-time')
+ curlWrappersChecked += 1
+ expect(curlWrappersChecked).toBeGreaterThan(0)
+ })
+
+ describe('dead-endpoint transport budget', () => {
+ let tempDir: string | null = null
+ let server: Server | null = null
+ const openSockets: Socket[] = []
+
+ afterEach(() => {
+ for (const socket of openSockets.splice(0)) {
+ socket.destroy()
+ }
+ if (server) {
+ server.close()
+ server = null
+ }
+ if (tempDir) {
+ rmSync(tempDir, { recursive: true, force: true })
+ tempDir = null
+ }
+ })
+
+ const hasPosixCurl =
+ process.platform !== 'win32' &&
+ spawnSync('sh', ['-c', 'command -v curl'], { encoding: 'utf8' }).status === 0
+
+ function runHookScript(scriptPath: string, port: number): Promise<{ status: number | null }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn('sh', [scriptPath], {
+ env: {
+ ...process.env,
+ ORCA_AGENT_HOOK_ENDPOINT: '',
+ ORCA_AGENT_HOOK_PORT: String(port),
+ ORCA_AGENT_HOOK_TOKEN: 'test-token',
+ ORCA_PANE_KEY: 'pane-1',
+ ORCA_TAB_ID: 'tab-1',
+ ORCA_WORKTREE_ID: 'wt-1'
+ },
+ stdio: ['pipe', 'ignore', 'ignore']
+ })
+ const timeout = setTimeout(() => {
+ child.kill('SIGKILL')
+ reject(new Error('hook script exceeded test timeout'))
+ }, 10_000)
+ child.on('error', (error) => {
+ clearTimeout(timeout)
+ reject(error)
+ })
+ child.on('close', (status) => {
+ clearTimeout(timeout)
+ resolve({ status })
+ })
+ child.stdin.end('{"hook_event_name":"Stop"}')
+ })
+ }
+
+ // Why: an accepted-but-unanswered connection is the worst-case hang that only
+ // `--max-time` (1.5s) governs — `--connect-timeout` already passes once the
+ // TCP handshake completes. Point the wrapper at a real loopback listener that
+ // accepts and then stalls, and assert the wrapper still returns well under a
+ // generous CI budget.
+ it.skipIf(!hasPosixCurl)(
+ 'returns within budget when the local listener accepts but never responds',
+ async () => {
+ // Reuse a real generated POSIX wrapper rather than re-deriving the script.
+ const { sftp, fs } = createFakeSftp()
+ await new CodexHookService().installRemote(sftp, REMOTE_HOME)
+ const wrapperBody = fs.files.get(`${REMOTE_HOME}/.orca/agent-hooks/codex-hook.sh`)!
+
+ tempDir = mkdtempSync(join(tmpdir(), 'orca-hook-timeout-'))
+ const scriptPath = join(tempDir, 'codex-hook.sh')
+ writeFileSync(scriptPath, wrapperBody, 'utf8')
+ chmodSync(scriptPath, 0o755)
+
+ const stallingServer = createServer((socket) => {
+ // Accept the connection and hold it open without ever replying.
+ openSockets.push(socket)
+ })
+ server = stallingServer
+ const port = await new Promise((resolve) => {
+ stallingServer.listen(0, '127.0.0.1', () => {
+ const address = stallingServer.address()
+ resolve(typeof address === 'object' && address ? address.port : 0)
+ })
+ })
+
+ const startedAt = Date.now()
+ const result = await runHookScript(scriptPath, port)
+ const elapsedMs = Date.now() - startedAt
+
+ // The hook fails open (`|| true`) and exits 0 even though the POST timed out.
+ expect(result.status).toBe(0)
+ expect(openSockets.length).toBeGreaterThan(0)
+ expect(elapsedMs).toBeGreaterThanOrEqual(1_000)
+ // --max-time 1.5s + process overhead; a hang would blow well past this.
+ expect(elapsedMs).toBeLessThan(6_000)
+ }
+ )
+ })
+})
diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts
index e78d4cf5f59..343c4d21552 100644
--- a/src/main/antigravity/hook-service.ts
+++ b/src/main/antigravity/hook-service.ts
@@ -6,9 +6,11 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
+ MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -228,10 +230,12 @@ function buildEventDefinition(event: AntigravityEvent, command: string): HookDef
if (event.schema === 'tool') {
return {
matcher: '*',
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
}
- return { type: 'command', command }
+ // Antigravity's direct-command event schema carries the command on the
+ // definition; add the host-level timeout backstop alongside it.
+ return { type: 'command', command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
function removeManagedCommandsFromBundle(
diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts
index fc94d6e2c6a..594cb7e332c 100644
--- a/src/main/claude/hook-settings.ts
+++ b/src/main/claude/hook-settings.ts
@@ -1,6 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
removeManagedCommands,
@@ -101,7 +102,7 @@ export function applyManagedHooks(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts
index 94f365a213a..cc72c4c3085 100644
--- a/src/main/codex/hook-service.test.ts
+++ b/src/main/codex/hook-service.test.ts
@@ -14,7 +14,7 @@ import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils'
-import { upsertHookTrustEntriesInContent } from './config-toml-trust'
+import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
@@ -1039,6 +1039,42 @@ describe('CodexHookService', () => {
expect(runtimeToml).not.toContain(':stop:0:0')
})
+ it('removes legacy managed trust entries hashed before hook timeouts existed', () => {
+ const service = new CodexHookService()
+ expect(service.install().state).toBe('installed')
+
+ const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
+ const managedHooksPath = join(managedCodexHome, 'hooks.json')
+ const runtimeTomlPath = join(managedCodexHome, 'config.toml')
+ const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
+ hooks: Record
+ }
+ const command = hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command
+ expect(command).toBeDefined()
+ const legacyHash = computeTrustedHash({
+ sourcePath: managedHooksPath,
+ eventLabel: 'permission_request',
+ groupIndex: 0,
+ handlerIndex: 0,
+ command: command!
+ })
+ writeFileSync(
+ runtimeTomlPath,
+ [
+ hookTrustHeader(`${managedHooksPath}:permission_request:0:0`),
+ 'enabled = true',
+ `trusted_hash = "${legacyHash}"`,
+ ''
+ ].join('\n'),
+ 'utf-8'
+ )
+
+ expect(service.remove().state).toBe('not_installed')
+
+ const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8')
+ expect(runtimeToml).not.toContain(':permission_request:0:0')
+ })
+
it('mirrors system Codex config while preserving runtime hook trust on hook install', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts
index 99638a787bc..15c415f94c0 100644
--- a/src/main/codex/hook-service.ts
+++ b/src/main/codex/hook-service.ts
@@ -4,10 +4,12 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
+ MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -608,14 +610,19 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void {
if (!managedEventLabels.has(parts.eventLabel)) {
continue
}
- const expectedHash = computeTrustedHash({
+ const expectedEntry: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
- command
- })
- if (state.trustedHash !== expectedHash) {
+ command,
+ // Why: match the timeout install() wrote, or remove() would fail to
+ // recognize (and clean up) its own managed trust entries.
+ timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
+ }
+ const expectedHash = computeTrustedHash(expectedEntry)
+ const legacyHash = computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
+ if (state.trustedHash !== expectedHash && state.trustedHash !== legacyHash) {
continue
}
ourKeys.push(key)
@@ -749,12 +756,16 @@ export class CodexHookService {
// Why: capture the actual handler index — Codex's hook_key uses the
// positional handlerIndex, and a user-merged hook array can put our
// command at a non-zero slot, so hardcoding 0 would misreport trust.
+ // Why: the managed hook is written with `timeout` (see install()), and
+ // Codex folds the handler timeout into its trust hash. Hash the same
+ // timeout here or status would report every managed hook as stale-trust.
const trustInput: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: foundGroupIndex,
handlerIndex: foundHandlerIndex,
- command
+ command,
+ timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const expectedHash = computeTrustedHash(trustInput)
const actualState = trustEntries.get(computeTrustKey(trustInput))
@@ -858,18 +869,21 @@ export class CodexHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [definition, ...cleaned]
// Why: the status hook must run before user hooks so a slow
// PostToolUse/Stop hook cannot leave the sidebar stuck on the previous
// state while Codex visibly reports that hooks are still running.
+ // timeoutSec mirrors the hook's `timeout` so the trust hash matches the
+ // entry actually written to hooks.json.
trustEntries.push({
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: 0,
handlerIndex: 0,
- command
+ command,
+ timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
@@ -946,7 +960,7 @@ export class CodexHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [...cleaned, definition]
trustEntries.push({
@@ -954,7 +968,8 @@ export class CodexHookService {
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
- command
+ command,
+ timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts
index a5a34a30512..64413bdd7c1 100644
--- a/src/main/command-code/hook-service.ts
+++ b/src/main/command-code/hook-service.ts
@@ -4,6 +4,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -212,7 +213,7 @@ function buildInstalledConfig(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts
index fb57ce1b8fd..1eb2a2f4e8f 100644
--- a/src/main/cursor/hook-service.ts
+++ b/src/main/cursor/hook-service.ts
@@ -3,6 +3,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandDefinition,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -230,7 +231,7 @@ export class CursorHookService {
// Why: Cursor's documented schema puts `command` directly on the
// definition (not under `hooks`). Emit that shape so cursor-agent
// actually invokes the script.
- const definition: HookDefinition = { command }
+ const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
}
@@ -280,7 +281,7 @@ export class CursorHookService {
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
- const definition: HookDefinition = { command }
+ const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
}
diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts
index 62cb4bd259c..873836897d9 100644
--- a/src/main/devin/hook-settings.ts
+++ b/src/main/devin/hook-settings.ts
@@ -1,6 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
removeManagedCommands,
@@ -74,7 +75,7 @@ export function applyDevinManagedHooks(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts
index f8e07cec3ab..ce61c9a010b 100644
--- a/src/main/droid/hook-service.ts
+++ b/src/main/droid/hook-service.ts
@@ -2,6 +2,7 @@ import { homedir } from 'os'
import { join } from 'path'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -211,7 +212,7 @@ export class DroidHookService {
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts
index 889e7a467f8..d724ced28f1 100644
--- a/src/main/gemini/hook-service.ts
+++ b/src/main/gemini/hook-service.ts
@@ -3,9 +3,11 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
+ MANAGED_HOOK_TIMEOUT_MILLISECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -204,7 +206,8 @@ export class GeminiHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
- hooks: [{ type: 'command', command }]
+ // Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
+ hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
}
nextHooks[eventName] = [...cleaned, definition]
}
@@ -261,7 +264,8 @@ export class GeminiHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
- hooks: [{ type: 'command', command }]
+ // Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
+ hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
}
nextHooks[eventName] = [...cleaned, definition]
}
diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts
index e8a81952a59..bbd881d7d1f 100644
--- a/src/main/grok/hook-service.ts
+++ b/src/main/grok/hook-service.ts
@@ -3,6 +3,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
+ buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -133,7 +134,7 @@ function buildInstalledConfig(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
- hooks: [{ type: 'command', command }]
+ hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts
index 5e80080704c..82118c67596 100644
--- a/src/main/kimi/kimi-hook-config-toml.ts
+++ b/src/main/kimi/kimi-hook-config-toml.ts
@@ -6,6 +6,8 @@
// untouched. Appending table headers is always valid TOML, so the block can live
// at the end of any existing file.
+import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
+
// Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses
// these exact event names (see normalizeKimiEvent), so each maps to a
// working/waiting/done transition.
@@ -55,8 +57,15 @@ export function buildManagedKimiHooksBlock(command: string): string {
const commandLiteral = tomlBasicString(command)
// Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is
// invalid) and an absent matcher already matches every tool.
+ // `timeout` is the host-level backstop; the shell wrapper's curl budget is
+ // the normal dead-endpoint bound.
const entries = KIMI_HOOK_EVENTS.map((event) =>
- [`[[hooks]]`, `event = "${event}"`, `command = ${commandLiteral}`].join('\n')
+ [
+ `[[hooks]]`,
+ `event = "${event}"`,
+ `command = ${commandLiteral}`,
+ `timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}`
+ ].join('\n')
)
return [BLOCK_START, ...entries, BLOCK_END].join('\n')
}
From 93bf26296fccf46aae911082399d97963a011dba Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 23 Jun 2026 20:41:45 -0700
Subject: [PATCH 13/18] Extract lifecycle message reconciliation to standalone
module (#6226)
- Decouple heartbeat and worker_done logic from the main coordinator
to allow re-use in the orchestration.check RPC method.
- Reconcile unread lifecycle messages during orchestration.check so that
manual coordinators can authoritatively process completions and
heartbeats even if they bypass the main coordinator loop.
- Add unit tests validating task completion, heartbeat recording, and
safety checks against stale or unauthorized worker_done payloads.
---
src/main/runtime/orchestration/coordinator.ts | 110 +----------
.../orchestration/lifecycle-reconciliation.ts | 144 ++++++++++++++
.../runtime/rpc/methods/orchestration.test.ts | 179 ++++++++++++++++++
src/main/runtime/rpc/methods/orchestration.ts | 7 +
4 files changed, 337 insertions(+), 103 deletions(-)
create mode 100644 src/main/runtime/orchestration/lifecycle-reconciliation.ts
diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts
index db17fc8d087..587dc4db7a8 100644
--- a/src/main/runtime/orchestration/coordinator.ts
+++ b/src/main/runtime/orchestration/coordinator.ts
@@ -2,6 +2,7 @@
import type { OrchestrationDb } from './db'
import type { MessageRow, TaskRow, CoordinatorStatus } from './types'
import { buildDispatchPreamble } from './preamble'
+import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
export type CoordinatorRuntime = {
sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise
@@ -249,7 +250,7 @@ export class Coordinator {
for (const msg of messages) {
switch (msg.type) {
case 'worker_done':
- this.handleWorkerDone(msg)
+ this.handleLifecycleMessage(msg)
break
case 'escalation':
this.handleEscalation(msg)
@@ -258,7 +259,7 @@ export class Coordinator {
this.handleDecisionGateMessage(msg)
break
case 'heartbeat':
- this.handleHeartbeat(msg)
+ this.handleLifecycleMessage(msg)
break
case 'status':
this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`)
@@ -273,108 +274,11 @@ export class Coordinator {
this.db.markAsRead(messages.map((m) => m.id))
}
- // Why: attribute heartbeats to the specific dispatchId, not a
- // (taskId, from_handle) lookup. A task that gets retried after a failed
- // dispatch has multiple rows in dispatch_contexts — a late heartbeat from
- // the previous (failed) assignee arriving while the new dispatch is active
- // would falsely bump the new row's last_heartbeat_at if we resolved by
- // "latest dispatch for this task" (§5.3.4). If the worker drops dispatchId
- // from the payload, log-and-skip is the preferred failure mode: the stale
- // detector will correctly flag the dispatch as hung because nothing
- // refreshed last_heartbeat_at.
- private handleHeartbeat(msg: MessageRow): void {
- if (!msg.payload) {
- this.opts.onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`)
- return
+ private handleLifecycleMessage(msg: MessageRow): void {
+ const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog)
+ if (result.action === 'completed') {
+ this.state.completedTasks.push(result.taskId)
}
- let payload: { dispatchId?: unknown } = {}
- try {
- payload = JSON.parse(msg.payload)
- } catch {
- this.opts.onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`)
- return
- }
- const dispatchId = payload.dispatchId
- if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
- this.opts.onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`)
- return
- }
- this.db.recordHeartbeat(dispatchId, msg.created_at)
- }
-
- private handleWorkerDone(msg: MessageRow): void {
- this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`)
-
- let payload: { taskId?: unknown; dispatchId?: unknown; filesModified?: unknown } = {}
- if (msg.payload) {
- try {
- payload = JSON.parse(msg.payload)
- } catch {
- this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`)
- }
- }
-
- const taskId = payload.taskId
- if (typeof taskId !== 'string' || taskId.length === 0) {
- this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`)
- return
- }
-
- const dispatchId = payload.dispatchId
- if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
- this.opts.onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`)
- return
- }
-
- const task = this.db.getTask(taskId)
- if (!task) {
- this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`)
- return
- }
-
- // Why: taskId alone is not a completion authority; retried tasks can have
- // stale worker_done messages racing the current active dispatch.
- const dispatch = this.db.getDispatchContextById(dispatchId)
- if (!dispatch) {
- this.opts.onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`)
- return
- }
- if (dispatch.task_id !== taskId) {
- this.opts.onLog(
- `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}`
- )
- return
- }
- if (dispatch.assignee_handle !== msg.from_handle) {
- this.opts.onLog(
- `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}`
- )
- return
- }
- if (dispatch.status !== 'dispatched') {
- this.opts.onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
- return
- }
- if (this.db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') {
- this.opts.onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`)
- return
- }
-
- const filesModified =
- Array.isArray(payload.filesModified) &&
- payload.filesModified.every((file) => typeof file === 'string')
- ? payload.filesModified
- : []
-
- const result = JSON.stringify({
- completedBy: msg.from_handle,
- filesModified,
- completedAt: new Date().toISOString()
- })
- this.db.updateTaskStatus(taskId, 'completed', result)
- this.state.completedTasks.push(taskId)
-
- this.opts.onLog(`Task ${taskId} completed`)
}
private handleEscalation(msg: MessageRow): void {
diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts
new file mode 100644
index 00000000000..9036c145534
--- /dev/null
+++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts
@@ -0,0 +1,144 @@
+import type { OrchestrationDb } from './db'
+import type { MessageRow } from './types'
+
+export type LifecycleReconciliationResult =
+ | { action: 'ignored' }
+ | { action: 'completed'; taskId: string; dispatchId: string }
+ | { action: 'heartbeat_recorded'; dispatchId: string }
+
+type LogFn = (msg: string) => void
+
+const noopLog: LogFn = () => {}
+
+function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record {
+ if (!msg.payload) {
+ return {}
+ }
+
+ try {
+ const parsed: unknown = JSON.parse(msg.payload)
+ return parsed && typeof parsed === 'object' ? (parsed as Record) : {}
+ } catch {
+ onInvalidJson()
+ return {}
+ }
+}
+
+export function reconcileLifecycleMessage(
+ db: OrchestrationDb,
+ msg: MessageRow,
+ onLog: LogFn = noopLog
+): LifecycleReconciliationResult {
+ switch (msg.type) {
+ case 'worker_done':
+ return reconcileWorkerDoneMessage(db, msg, onLog)
+ case 'heartbeat':
+ return reconcileHeartbeatMessage(db, msg, onLog)
+ case 'status':
+ case 'dispatch':
+ case 'merge_ready':
+ case 'escalation':
+ case 'handoff':
+ case 'decision_gate':
+ return { action: 'ignored' }
+ }
+}
+
+function reconcileHeartbeatMessage(
+ db: OrchestrationDb,
+ msg: MessageRow,
+ onLog: LogFn
+): LifecycleReconciliationResult {
+ if (!msg.payload) {
+ onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`)
+ return { action: 'ignored' }
+ }
+
+ const payload = parseObjectPayload(msg, () => {
+ onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`)
+ })
+ const dispatchId = payload.dispatchId
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
+ onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`)
+ return { action: 'ignored' }
+ }
+
+ // Why: dispatchId-specific writes let the DB ignore late heartbeats for
+ // completed/failed retries without masking a newer hung dispatch.
+ db.recordHeartbeat(dispatchId, msg.created_at)
+ return { action: 'heartbeat_recorded', dispatchId }
+}
+
+function reconcileWorkerDoneMessage(
+ db: OrchestrationDb,
+ msg: MessageRow,
+ onLog: LogFn
+): LifecycleReconciliationResult {
+ onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`)
+
+ const payload = parseObjectPayload(msg, () => {
+ onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`)
+ })
+
+ const taskId = payload.taskId
+ if (typeof taskId !== 'string' || taskId.length === 0) {
+ onLog(`Warning: worker_done without taskId from ${msg.from_handle}`)
+ return { action: 'ignored' }
+ }
+
+ const dispatchId = payload.dispatchId
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
+ onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`)
+ return { action: 'ignored' }
+ }
+
+ const task = db.getTask(taskId)
+ if (!task) {
+ onLog(`Warning: worker_done for unknown task ${taskId}`)
+ return { action: 'ignored' }
+ }
+
+ // Why: taskId alone is not a completion authority; retried tasks can have
+ // stale worker_done messages racing the current active dispatch.
+ const dispatch = db.getDispatchContextById(dispatchId)
+ if (!dispatch) {
+ onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`)
+ return { action: 'ignored' }
+ }
+ if (dispatch.task_id !== taskId) {
+ onLog(
+ `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}`
+ )
+ return { action: 'ignored' }
+ }
+ if (dispatch.assignee_handle !== msg.from_handle) {
+ onLog(
+ `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}`
+ )
+ return { action: 'ignored' }
+ }
+ if (dispatch.status !== 'dispatched') {
+ onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
+ return { action: 'ignored' }
+ }
+ if (db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') {
+ onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`)
+ return { action: 'ignored' }
+ }
+
+ const filesModified =
+ Array.isArray(payload.filesModified) &&
+ payload.filesModified.every((file) => typeof file === 'string')
+ ? payload.filesModified
+ : []
+
+ const result = JSON.stringify({
+ completedBy: msg.from_handle,
+ filesModified,
+ completedAt: new Date().toISOString()
+ })
+ db.updateTaskStatus(taskId, 'completed', result)
+
+ onLog(`Task ${taskId} completed`)
+ return { action: 'completed', taskId, dispatchId }
+}
diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts
index 190a3ec2b90..368b77b2072 100644
--- a/src/main/runtime/rpc/methods/orchestration.test.ts
+++ b/src/main/runtime/rpc/methods/orchestration.test.ts
@@ -377,6 +377,39 @@ describe('orchestration RPC methods', () => {
})
describe('orchestration.check', () => {
+ function createDispatchedTask(assigneeHandle = 'term_worker') {
+ const task = db.createTask({ spec: 'manual check work' })
+ const dispatch = db.createDispatchContext(task.id, assigneeHandle)
+ return { task, dispatch }
+ }
+
+ function insertWorkerDone(params: {
+ from?: string
+ to?: string
+ taskId?: string
+ dispatchId?: string
+ filesModified?: string[]
+ }): void {
+ const payload: Record = {}
+ if (params.taskId !== undefined) {
+ payload.taskId = params.taskId
+ }
+ if (params.dispatchId !== undefined) {
+ payload.dispatchId = params.dispatchId
+ }
+ if (params.filesModified !== undefined) {
+ payload.filesModified = params.filesModified
+ }
+
+ db.insertMessage({
+ from: params.from ?? 'term_worker',
+ to: params.to ?? 'term_coord',
+ subject: 'Done',
+ type: 'worker_done',
+ payload: JSON.stringify(payload)
+ })
+ }
+
it('returns unread messages for a terminal', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'one' })
@@ -416,6 +449,152 @@ describe('orchestration RPC methods', () => {
expect(result.count).toBe(1)
})
+ it('reconciles worker_done returned by a waiting manual check', async () => {
+ setup()
+ const { task, dispatch } = createDispatchedTask()
+ vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => {
+ insertWorkerDone({
+ taskId: task.id,
+ dispatchId: dispatch.id,
+ filesModified: ['src/file.ts']
+ })
+ })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ wait: true,
+ timeoutMs: 100,
+ types: 'worker_done,escalation,decision_gate'
+ })) as { count: number; messages: { type: string }[] }
+
+ expect(result.count).toBe(1)
+ expect(result.messages[0].type).toBe('worker_done')
+ expect(db.getTask(task.id)?.status).toBe('completed')
+ expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed')
+ expect(db.getUnreadMessages('term_coord')).toHaveLength(0)
+ const taskList = (await call('orchestration.taskList', {})) as {
+ tasks: {
+ id: string
+ status: string
+ assignee_handle?: string | null
+ dispatch_id?: string | null
+ }[]
+ }
+ const listedTask = taskList.tasks.find((t) => t.id === task.id)
+ expect(listedTask?.status).toBe('completed')
+ expect(listedTask).not.toHaveProperty('assignee_handle')
+ expect(listedTask).not.toHaveProperty('dispatch_id')
+ const shownDispatch = (await call('orchestration.dispatchShow', {
+ task: task.id
+ })) as { dispatch: { status: string } | null }
+ expect(shownDispatch.dispatch?.status).toBe('completed')
+
+ const completedAt = db.getTask(task.id)?.completed_at
+ const taskResult = db.getTask(task.id)?.result
+ const repeated = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ types: 'worker_done'
+ })) as { count: number }
+ expect(repeated.count).toBe(0)
+ expect(db.getTask(task.id)?.completed_at).toBe(completedAt)
+ expect(db.getTask(task.id)?.result).toBe(taskResult)
+ })
+
+ it('keeps check --all read-only for lifecycle messages', async () => {
+ setup()
+ const { task, dispatch } = createDispatchedTask()
+ insertWorkerDone({ taskId: task.id, dispatchId: dispatch.id })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ all: true,
+ types: 'worker_done'
+ })) as { count: number }
+
+ expect(result.count).toBe(1)
+ expect(db.getTask(task.id)?.status).toBe('dispatched')
+ expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
+ expect(db.getUnreadMessages('term_coord', ['worker_done'])).toHaveLength(1)
+ })
+
+ it('does not complete worker_done missing taskId or dispatchId', async () => {
+ setup()
+ const { task, dispatch } = createDispatchedTask()
+ insertWorkerDone({ dispatchId: dispatch.id })
+ insertWorkerDone({ taskId: task.id })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ types: 'worker_done'
+ })) as { count: number }
+
+ expect(result.count).toBe(2)
+ expect(db.getTask(task.id)?.status).toBe('dispatched')
+ expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
+ })
+
+ it('does not complete worker_done from a terminal that does not own the dispatch', async () => {
+ setup()
+ const { task, dispatch } = createDispatchedTask('term_owner')
+ insertWorkerDone({
+ from: 'term_intruder',
+ taskId: task.id,
+ dispatchId: dispatch.id
+ })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ types: 'worker_done'
+ })) as { count: number }
+
+ expect(result.count).toBe(1)
+ expect(db.getTask(task.id)?.status).toBe('dispatched')
+ expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
+ })
+
+ it('does not complete worker_done for a stale inactive dispatch', async () => {
+ setup()
+ const task = db.createTask({ spec: 'retry-sensitive work' })
+ const staleDispatch = db.createDispatchContext(task.id, 'term_old')
+ db.failDispatch(staleDispatch.id, 'retry elsewhere')
+ const activeDispatch = db.createDispatchContext(task.id, 'term_current')
+ insertWorkerDone({
+ from: 'term_old',
+ taskId: task.id,
+ dispatchId: staleDispatch.id
+ })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ types: 'worker_done'
+ })) as { count: number }
+
+ expect(result.count).toBe(1)
+ expect(db.getTask(task.id)?.status).toBe('dispatched')
+ expect(db.getDispatchContextById(staleDispatch.id)?.status).toBe('failed')
+ expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched')
+ })
+
+ it('records heartbeat returned by unread manual check', async () => {
+ setup()
+ const { task, dispatch } = createDispatchedTask()
+ const msg = db.insertMessage({
+ from: 'term_worker',
+ to: 'term_coord',
+ subject: 'alive',
+ type: 'heartbeat',
+ payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
+ })
+
+ const result = (await call('orchestration.check', {
+ terminal: 'term_coord',
+ types: 'heartbeat'
+ })) as { count: number }
+
+ expect(result.count).toBe(1)
+ expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBe(msg.created_at)
+ })
+
it('rejects invalid type filters', async () => {
setup()
await expect(
diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts
index bd5f950815b..64a0b9969a7 100644
--- a/src/main/runtime/rpc/methods/orchestration.ts
+++ b/src/main/runtime/rpc/methods/orchestration.ts
@@ -6,6 +6,7 @@ import type { MessageType, MessagePriority, TaskStatus } from '../../orchestrati
import { buildDispatchPreamble } from '../../orchestration/preamble'
import { formatMessageBanner } from '../../orchestration/formatter'
import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups'
+import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation'
import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates'
const MESSAGE_TYPES: MessageType[] = [
@@ -265,6 +266,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
: db.getAllMessagesForHandle(handle, undefined, typeFilter)
if (showUnread && messages.length > 0) {
+ // Why: manual coordinators can consume lifecycle messages before
+ // the coordinator loop sees them, but unread `check` is still an
+ // authoritative read path for worker_done/heartbeat.
+ for (const message of messages) {
+ reconcileLifecycleMessage(db, message)
+ }
db.markAsRead(messages.map((m) => m.id))
}
From 5fc1fb3c25531fa3cf5a3ca182234e86427a92a8 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 23 Jun 2026 20:59:06 -0700
Subject: [PATCH 14/18] Coalesce renderer process-gone crash bursts (#6202)
Co-authored-by: Neil
---
.../process-gone-classification.test.ts | 50 +++++++++++++++++++
.../process-gone-classification.ts | 5 +-
.../process-gone-dedupe.test.ts | 36 ++++++++++++-
.../crash-reporting/process-gone-dedupe.ts | 8 ++-
src/main/index.ts | 2 +-
5 files changed, 97 insertions(+), 4 deletions(-)
diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts
index e143729fb29..50740f22add 100644
--- a/src/main/crash-reporting/process-gone-classification.test.ts
+++ b/src/main/crash-reporting/process-gone-classification.test.ts
@@ -201,6 +201,16 @@ describe('shouldRecordProcessGoneCrash', () => {
expectedTeardown: 'none'
})
).toBe(false)
+ expect(
+ shouldRecordProcessGoneCrash({
+ source: 'child',
+ processType: 'Utility',
+ serviceName: 'video_capture.mojom.VideoCaptureService',
+ reason: 'killed',
+ exitCode: 1,
+ expectedTeardown: 'none'
+ })
+ ).toBe(false)
})
it('still records unknown child process crashes', () => {
@@ -236,6 +246,16 @@ describe('shouldRecordProcessGoneCrash', () => {
expectedTeardown: 'none'
})
).toBe(true)
+ expect(
+ shouldRecordProcessGoneCrash({
+ source: 'child',
+ processType: 'Utility',
+ serviceName: 'video_capture.mojom.VideoCaptureService',
+ reason: 'launch-failed',
+ exitCode: -1,
+ expectedTeardown: 'none'
+ })
+ ).toBe(true)
})
it('still records renderer kills from the recent Linux crash-report cluster', () => {
@@ -274,6 +294,36 @@ describe('shouldRecordProcessGoneCrash', () => {
).toBe(true)
})
+ it('records Windows renderer killed exit 1 outside expected lifecycle teardown only', () => {
+ expect(
+ shouldRecordProcessGoneCrash({
+ source: 'renderer',
+ processType: 'renderer',
+ reason: 'killed',
+ exitCode: 1,
+ expectedTeardown: 'none'
+ })
+ ).toBe(true)
+ expect(
+ shouldRecordProcessGoneCrash({
+ source: 'renderer',
+ processType: 'renderer',
+ reason: 'killed',
+ exitCode: 1,
+ expectedTeardown: 'renderer-reload'
+ })
+ ).toBe(false)
+ expect(
+ shouldRecordProcessGoneCrash({
+ source: 'renderer',
+ processType: 'renderer',
+ reason: 'killed',
+ exitCode: 1,
+ expectedTeardown: 'app-shutdown'
+ })
+ ).toBe(false)
+ })
+
it('records non-SIGTERM child-process killed events during renderer-only reloads', () => {
expect(
shouldRecordProcessGoneCrash({
diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts
index 577f40e2ab9..c9042ede715 100644
--- a/src/main/crash-reporting/process-gone-classification.ts
+++ b/src/main/crash-reporting/process-gone-classification.ts
@@ -5,7 +5,10 @@ const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004])
const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu'])
const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'audio.mojom.AudioService',
- 'network.mojom.NetworkService'
+ 'network.mojom.NetworkService',
+ // Why: Windows media/screen capture can churn this Chromium utility without
+ // taking down Orca; prompting users for those child exits is noise.
+ 'video_capture.mojom.VideoCaptureService'
])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure', 'launch-failed'])
diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts
index 521e543d0fa..6beebfd4926 100644
--- a/src/main/crash-reporting/process-gone-dedupe.test.ts
+++ b/src/main/crash-reporting/process-gone-dedupe.test.ts
@@ -4,7 +4,7 @@ import { getProcessGoneDedupeKey, ProcessGoneDedupe } from './process-gone-dedup
describe('ProcessGoneDedupe', () => {
it('suppresses duplicate keys inside the dedupe window', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
- const key = getProcessGoneDedupeKey('GPU', 'crashed', 5)
+ const key = getProcessGoneDedupeKey('child', 'GPU', 'crashed', 5)
expect(dedupe.shouldRecord(key, 1_000)).toBe(true)
expect(dedupe.shouldRecord(key, 2_999)).toBe(false)
@@ -32,4 +32,38 @@ describe('ProcessGoneDedupe', () => {
expect(dedupe.size).toBe(3)
expect(dedupe.shouldRecord('a', 1_004)).toBe(true)
})
+
+ it('coalesces renderer crash bursts across crash reasons and exit codes', () => {
+ const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
+
+ expect(
+ dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', -36861), 1_000)
+ ).toBe(true)
+ expect(
+ dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904), 1_284)
+ ).toBe(false)
+ expect(
+ dedupe.shouldRecord(
+ getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
+ 1_898
+ )
+ ).toBe(false)
+ expect(
+ dedupe.shouldRecord(
+ getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
+ 3_000
+ )
+ ).toBe(true)
+ })
+
+ it('keeps child process crash tuples distinct inside renderer burst windows', () => {
+ const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
+
+ expect(
+ dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'crashed', 1), 1_000)
+ ).toBe(true)
+ expect(
+ dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'killed', 1), 1_100)
+ ).toBe(true)
+ })
})
diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts
index f2caf0f3d1f..eb746a18e57 100644
--- a/src/main/crash-reporting/process-gone-dedupe.ts
+++ b/src/main/crash-reporting/process-gone-dedupe.ts
@@ -54,11 +54,17 @@ export class ProcessGoneDedupe {
}
export function getProcessGoneDedupeKey(
+ source: 'renderer' | 'child',
processType: string,
reason: string,
exitCode: number | null
): string {
- return `${processType}:${reason}:${exitCode ?? 'null'}`
+ // Why: one renderer death can surface as crashed/oom/launch-failed in a
+ // burst. Coalesce that prompt noise while keeping child identities precise.
+ if (source === 'renderer') {
+ return `${source}:${processType}`
+ }
+ return `${source}:${processType}:${reason}:${exitCode ?? 'null'}`
}
export const processGoneDedupe = new ProcessGoneDedupe()
diff --git a/src/main/index.ts b/src/main/index.ts
index d684845923a..f879520bed3 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -920,7 +920,7 @@ function recordProcessGoneCrash(
)
return
}
- const key = getProcessGoneDedupeKey(processType, reason, exitCode)
+ const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
if (!processGoneDedupe.shouldRecord(key)) {
return
}
From b21f8cd230d1608085605e56a421ea0cc24fd6b7 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 23 Jun 2026 21:00:42 -0700
Subject: [PATCH 15/18] Scope terminal handle links by runtime and focus
mounted tasks directly (#6225)
* Match terminal handle links using their owning runtime environment ID to prevent false-positive activation when terminal handles collide across local and remote runtimes.
* Focus task-associated terminals directly in the renderer when they are already mounted to avoid redundant runtime RPC focus calls.
---
.../terminal-handle-links.test.ts | 239 +++++++++++++++++-
.../terminal-pane/terminal-handle-links.ts | 44 +++-
.../terminal-orchestration-task-links.ts | 6 +-
3 files changed, 277 insertions(+), 12 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
index ddb842b66f1..b4d12bfb12f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts
@@ -294,16 +294,80 @@ describe('createTerminalHandleLinkProvider', () => {
})
})
+ it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => {
+ markRuntimeEnvironmentCompatible('env-1')
+ mocks.storeState.tabsByWorktree = {
+ 'wt-local': [
+ {
+ id: 'tab-local',
+ worktreeId: 'wt-local',
+ ptyId: 'term_worker',
+ title: 'Local worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ],
+ 'wt-env-2': [
+ {
+ id: 'tab-env-2',
+ worktreeId: 'wt-env-2',
+ ptyId: 'remote:env-2@@term_worker',
+ title: 'Other remote worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ }
+ mocks.storeState.ptyIdsByTabId = {
+ 'tab-local': ['term_worker'],
+ 'tab-env-2': ['remote:env-2@@term_worker']
+ }
+ mocks.storeState.terminalLayoutsByTabId = {
+ 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null },
+ 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null }
+ }
+ window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({
+ ok: true,
+ result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } }
+ })
+ const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, 'env-1')
+
+ links[0].activate(
+ {
+ metaKey: true,
+ ctrlKey: false,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent,
+ links[0].text
+ )
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local')
+ expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2')
+ expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.focus',
+ params: { terminal: 'term_worker' },
+ timeoutMs: undefined
+ })
+ })
+
it('provides wrapped task links and focuses their dispatched terminal through runtime', async () => {
const rows = [makeBufferLine('Task: task_work'), makeBufferLine('er', { isWrapped: true })]
const links = await collectLinks(rows, 1)
+ const preventDefault = vi.fn()
expect(links.map((link) => link.text)).toEqual(['task_worker'])
links[0].activate(
{
metaKey: true,
ctrlKey: false,
- preventDefault: vi.fn()
+ preventDefault
} as unknown as MouseEvent,
links[0].text
)
@@ -318,6 +382,179 @@ describe('createTerminalHandleLinkProvider', () => {
method: 'terminal.focus',
params: { terminal: 'term_worker' }
})
+ expect(preventDefault).toHaveBeenCalled()
+ })
+
+ it('focuses resolved task terminals directly when they are already mounted', async () => {
+ mocks.storeState.tabsByWorktree = {
+ 'wt-1': [
+ {
+ id: 'tab-1',
+ worktreeId: 'wt-1',
+ ptyId: 'term_worker',
+ title: 'Worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ }
+ mocks.storeState.ptyIdsByTabId = { 'tab-1': ['term_worker'] }
+ mocks.storeState.terminalLayoutsByTabId = {
+ 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
+ }
+ const links = await collectLinks([makeBufferLine('Task: task_worker')])
+
+ links[0].activate(
+ {
+ metaKey: true,
+ ctrlKey: false,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent,
+ links[0].text
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(window.api.runtime.call).toHaveBeenCalledTimes(1)
+ expect(window.api.runtime.call).toHaveBeenCalledWith({
+ method: 'orchestration.dispatchShow',
+ params: { task: 'task_worker' }
+ })
+ expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-1')
+ expect(mocks.storeState.markWorktreeVisited).toHaveBeenCalledWith('wt-1')
+ expect(mocks.storeState.setActiveView).toHaveBeenCalledWith('terminal')
+ expect(mocks.storeState.setActiveTabType).toHaveBeenCalledWith('terminal')
+ expect(mocks.storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1')
+ expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-1')
+ expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1')
+ })
+
+ it('focuses mounted task terminals only when they belong to the owning runtime', async () => {
+ markRuntimeEnvironmentCompatible('env-1')
+ mocks.storeState.tabsByWorktree = {
+ 'wt-remote': [
+ {
+ id: 'tab-remote',
+ worktreeId: 'wt-remote',
+ ptyId: 'remote:env-1@@term_remote',
+ title: 'Remote worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ }
+ mocks.storeState.ptyIdsByTabId = { 'tab-remote': ['remote:env-1@@term_remote'] }
+ mocks.storeState.terminalLayoutsByTabId = {
+ 'tab-remote': { root: null, activeLeafId: null, expandedLeafId: null }
+ }
+ window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({
+ ok: true,
+ result: { dispatch: { assignee_handle: 'term_remote' } }
+ })
+ const links = await collectLinks([makeBufferLine('Task: task_remote')], 1, 'env-1')
+
+ links[0].activate(
+ {
+ metaKey: true,
+ ctrlKey: false,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent,
+ links[0].text
+ )
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(window.api.runtimeEnvironments.call).toHaveBeenCalledTimes(1)
+ expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'orchestration.dispatchShow',
+ params: { task: 'task_remote' },
+ timeoutMs: undefined
+ })
+ expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-remote')
+ expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-remote')
+ })
+
+ it('falls back to the owning runtime when task handles collide across runtimes', async () => {
+ markRuntimeEnvironmentCompatible('env-1')
+ mocks.storeState.tabsByWorktree = {
+ 'wt-local': [
+ {
+ id: 'tab-local',
+ worktreeId: 'wt-local',
+ ptyId: 'term_worker',
+ title: 'Local worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ],
+ 'wt-env-2': [
+ {
+ id: 'tab-env-2',
+ worktreeId: 'wt-env-2',
+ ptyId: 'remote:env-2@@term_worker',
+ title: 'Other remote worker',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ }
+ mocks.storeState.ptyIdsByTabId = {
+ 'tab-local': ['term_worker'],
+ 'tab-env-2': ['remote:env-2@@term_worker']
+ }
+ mocks.storeState.terminalLayoutsByTabId = {
+ 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null },
+ 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null }
+ }
+ window.api.runtimeEnvironments.call = vi.fn().mockImplementation(({ method }) => {
+ if (method === 'orchestration.dispatchShow') {
+ return Promise.resolve({
+ ok: true,
+ result: { dispatch: { assignee_handle: 'term_worker' } }
+ })
+ }
+ return Promise.resolve({
+ ok: true,
+ result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } }
+ })
+ })
+ const links = await collectLinks([makeBufferLine('Task: task_worker')], 1, 'env-1')
+
+ links[0].activate(
+ {
+ metaKey: true,
+ ctrlKey: false,
+ preventDefault: vi.fn()
+ } as unknown as MouseEvent,
+ links[0].text
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local')
+ expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2')
+ expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(1, {
+ selector: 'env-1',
+ method: 'orchestration.dispatchShow',
+ params: { task: 'task_worker' },
+ timeoutMs: undefined
+ })
+ expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, {
+ selector: 'env-1',
+ method: 'terminal.focus',
+ params: { terminal: 'term_worker' },
+ timeoutMs: undefined
+ })
})
it('returns task and terminal links in line order', async () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
index d692e68b897..c11d595f611 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts
@@ -4,7 +4,7 @@ import { useAppStore } from '@/store'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
-import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream'
+import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import {
extractOrchestrationTaskLinks,
@@ -96,13 +96,14 @@ function findPrefixedTokenEnd(lineText: string, startIndex: number): number {
export function findTerminalHandleTarget(
handle: string,
- state: TerminalHandleFocusState
+ state: TerminalHandleFocusState,
+ runtimeEnvironmentId?: string | null
): TerminalHandleTarget | null {
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
for (const tab of tabs) {
const layout = state.terminalLayoutsByTabId[tab.id]
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
- if (ptyIdMatchesTerminalHandle(ptyId, handle)) {
+ if (ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId)) {
return { worktreeId, tabId: tab.id, leafId }
}
}
@@ -110,7 +111,9 @@ export function findTerminalHandleTarget(
const tabPtyIds = [tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])].filter(
(ptyId): ptyId is string => Boolean(ptyId)
)
- if (tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle))) {
+ if (
+ tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId))
+ ) {
return { worktreeId, tabId: tab.id, leafId: layout?.activeLeafId ?? null }
}
}
@@ -118,9 +121,12 @@ export function findTerminalHandleTarget(
return null
}
-export function focusRendererTerminalHandle(handle: string): boolean {
+export function focusRendererTerminalHandle(
+ handle: string,
+ runtimeEnvironmentId?: string | null
+): boolean {
const store = useAppStore.getState()
- const target = findTerminalHandleTarget(handle, store)
+ const target = findTerminalHandleTarget(handle, store, runtimeEnvironmentId)
if (!target) {
return false
}
@@ -211,21 +217,39 @@ async function activateParsedLink(
): Promise {
try {
if (parsed.kind === 'terminal') {
- if (!focusRendererTerminalHandle(parsed.text)) {
+ if (!focusRendererTerminalHandle(parsed.text, runtimeEnvironmentId)) {
await focusRuntimeTerminalHandle(parsed.text, runtimeEnvironmentId)
}
return
}
// Why: a task can be retried onto a new dispatch; runtime DB is the
// authority for the latest terminal assigned to a stable task ID.
- await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId)
+ await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId, (handle) =>
+ focusRendererTerminalHandle(handle, runtimeEnvironmentId)
+ )
} catch (error: unknown) {
console.warn('[terminal-handle-link] focus failed:', error)
}
}
-function ptyIdMatchesTerminalHandle(ptyId: string, handle: string): boolean {
- return ptyId === handle || getRemoteRuntimeTerminalHandle(ptyId) === handle
+function ptyIdMatchesTerminalHandle(
+ ptyId: string,
+ handle: string,
+ runtimeEnvironmentId?: string | null
+): boolean {
+ const targetEnvironmentId = runtimeEnvironmentId?.trim() || null
+ if (ptyId === handle) {
+ return targetEnvironmentId === null
+ }
+ const remotePty = parseRemoteRuntimePtyId(ptyId)
+ if (!remotePty || remotePty.handle !== handle) {
+ return false
+ }
+ const ptyEnvironmentId = remotePty.environmentId?.trim() || null
+ if (runtimeEnvironmentId === undefined) {
+ return true
+ }
+ return ptyEnvironmentId === targetEnvironmentId
}
function getTerminalHandleFocusHint(): string {
diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts
index 99acf41f12c..fdd90fb4019 100644
--- a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts
@@ -46,7 +46,8 @@ export function extractOrchestrationTaskLinks(lineText: string): ParsedOrchestra
export async function focusRuntimeOrchestrationTask(
taskId: string,
- runtimeEnvironmentId: string | null
+ runtimeEnvironmentId: string | null,
+ focusRendererTerminal?: (handle: string) => boolean
): Promise {
const environmentId = runtimeEnvironmentId?.trim()
const target = environmentId
@@ -59,6 +60,9 @@ export async function focusRuntimeOrchestrationTask(
if (!terminal) {
throw new Error(`No dispatched terminal for orchestration task ${taskId}`)
}
+ if (focusRendererTerminal?.(terminal)) {
+ return
+ }
// Why: task IDs are stable orchestration DB records, but terminal.focus owns
// the app-side navigation contract for local and SSH runtime terminals.
await callRuntimeRpc(target, 'terminal.focus', { terminal })
From 23d14aaa18885e20064d6199d6ebe0df238ab729 Mon Sep 17 00:00:00 2001
From: Trevin Chow
Date: Tue, 23 Jun 2026 21:03:13 -0700
Subject: [PATCH 16/18] fix: reword folder grouping prompt away from "monorepo"
Reword the nested repository import dialog so it asks users whether to group discovered repositories rather than asserting they are a monorepo.
Also keeps locale catalogs aligned with live nested-import strings and updates Electron folder-import coverage for the new affirmative action label.
---
.../sidebar/AddRepoDialogStepContent.test.tsx | 16 ++++++-------
.../sidebar/AddRepoNestedImportStep.test.tsx | 24 +++++++++----------
.../sidebar/AddRepoNestedImportStep.tsx | 6 ++---
src/renderer/src/i18n/locales/en.json | 13 ++++------
src/renderer/src/i18n/locales/es.json | 13 ++++------
src/renderer/src/i18n/locales/ja.json | 13 ++++------
src/renderer/src/i18n/locales/ko.json | 13 ++++------
src/renderer/src/i18n/locales/zh.json | 13 ++++------
.../e2e/folder-setup-shallow-priority.spec.ts | 14 +++++------
tests/e2e/folder-setup.spec.ts | 12 +++++-----
10 files changed, 55 insertions(+), 82 deletions(-)
diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
index 63dc178eab7..26ec195d440 100644
--- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
@@ -100,22 +100,22 @@ function renderNestedStep(repoCount: number): string {
}
describe('AddRepoDialogStepContent nested imports', () => {
- it('asks the monorepo question when no repos exist yet', () => {
+ it('asks the grouping question when no repos exist yet', () => {
const html = renderNestedStep(0)
- expect(html).toContain('Is this a monorepo?')
- expect(html).toContain('aria-label="Monorepo name"')
- expect(html).toContain('Yes, import as monorepo')
+ expect(html).toContain('Group these repositories?')
+ expect(html).toContain('aria-label="Group name"')
+ expect(html).toContain('Yes, import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import')
})
- it('shows the same monorepo import controls after a repo already exists', () => {
+ it('shows the same grouping import controls after a repo already exists', () => {
const html = renderNestedStep(1)
- expect(html).toContain('Is this a monorepo?')
- expect(html).toContain('aria-label="Monorepo name"')
- expect(html).toContain('Yes, import as monorepo')
+ expect(html).toContain('Group these repositories?')
+ expect(html).toContain('aria-label="Group name"')
+ expect(html).toContain('Yes, import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import')
})
diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx
index 13be2021c7d..ba9022ca742 100644
--- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx
@@ -72,19 +72,19 @@ describe('AddRepoNestedImportStep', () => {
container = null
})
- it('asks whether the selected folder is a monorepo', () => {
+ it('asks whether the selected folder should be grouped', () => {
const html = renderStepMarkup()
expect(html).toContain('Import repositories from folder')
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('/workspace/platform')
- expect(html).toContain('aria-label="Monorepo name"')
+ expect(html).toContain('aria-label="Group name"')
expect(html).not.toContain('What is a')
- expect(html).toContain('Is this a monorepo?')
+ expect(html).toContain('Group these repositories?')
expect(html).toContain('Choose this if these projects belong together')
expect(html).toContain('Orca will group them and let you work from the parent folder')
expect(html).toContain('No, import separately')
- expect(html).toContain('Yes, import as monorepo')
+ expect(html).toContain('Yes, import as group')
expect(html).toContain('payments/api')
expect(html).toContain('billing/api')
expect(html).not.toContain('disabled=""')
@@ -95,14 +95,14 @@ describe('AddRepoNestedImportStep', () => {
it('disables both import actions while scanning', () => {
const html = renderStepMarkup({ scanInProgress: true })
- expect(html).toContain('Is this a monorepo?')
+ expect(html).toContain('Group these repositories?')
expect(html).toContain('No, import separately')
- expect(html).toContain('Yes, import as monorepo')
+ expect(html).toContain('Yes, import as group')
expect(html).toMatch(/
@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({
{showGroupSpinner ?
: null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
- 'Import as group'
+ 'Yes, import as group'
)}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index e6ed96882bf..c05c5058070 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -3384,22 +3384,17 @@
"496f68cf8c": "Scanning repositories. Click to stop.",
"a32bef9516": "Stop scanning",
"2f8298f3c3": "Stop scan",
- "c157f31a95": "Import as group",
- "40199ef7b3": "Group name",
- "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
- "787412361a": "What is a group name?",
"5f857ba8e6": "in",
"4df0d08cc5": "Found",
"8db50afe1a": "Import repositories from folder",
"5b2e6fe3c8": "Import separately",
"cf9d382ca1": "Import",
"220dd32d83": "Scanning...",
- "fb33359f69": "Is this a monorepo?",
- "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.",
- "39d51212cc": "Monorepo name",
- "e907ec8935": "What is a monorepo name?",
+ "fb33359f69": "Group these repositories?",
+ "d75170194e": "Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.",
+ "39d51212cc": "Group name",
"aa0247680d": "No, import separately",
- "a0bc4d1f8e": "Yes, import as monorepo",
+ "a0bc4d1f8e": "Yes, import as group",
"8401a7a0d0": "1 repository",
"d4f1df62ef": "{{value0}} repositories",
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 4ec8e276dd3..1aad6ed2381 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -3378,26 +3378,21 @@
"496f68cf8c": "Escaneo de repositorios. Haga clic para detener.",
"a32bef9516": "Dejar de escanear",
"2f8298f3c3": "Detener escaneo",
- "c157f31a95": "Importar como grupo",
- "40199ef7b3": "Nombre del grupo",
- "787412361a": "¿Qué es el nombre de un grupo?",
"5f857ba8e6": "en",
"4df0d08cc5": "Encontró",
"8db50afe1a": "Importar repositorios desde la carpeta",
"5b2e6fe3c8": "Importar por separado",
"cf9d382ca1": "Importar",
"220dd32d83": "Exploración...",
- "fb33359f69": "¿Es esto un monorepo?",
- "d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
+ "fb33359f69": "¿Agrupar estos repositorios?",
+ "d75170194e": "Elige esto si estos proyectos van juntos: un monorepo o simplemente un conjunto de repositorios relacionados. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"39d51212cc": "Nombre del grupo",
"aa0247680d": "No, importar por separado",
- "a0bc4d1f8e": "Importar como grupo",
+ "a0bc4d1f8e": "Sí, importar como grupo",
"8401a7a0d0": "1 repositorio",
"d4f1df62ef": "{{value0}} repositorios",
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
- "24eda6c8b2": "Explorando... {{value0}}",
- "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
- "e907ec8935": "What is a monorepo name?"
+ "24eda6c8b2": "Explorando... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "Detener escaneo",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8fa3a8a09b4..3e2ac5ce512 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -3359,26 +3359,21 @@
"496f68cf8c": "repos をスキャンしています。クリックして停止します。",
"a32bef9516": "スキャンを停止する",
"2f8298f3c3": "スキャンの停止",
- "c157f31a95": "グループとしてインポート",
- "40199ef7b3": "グループ名",
- "787412361a": "グループ名とは何ですか?",
"5f857ba8e6": "で",
"4df0d08cc5": "見つかった",
"8db50afe1a": "フォルダーから repos をインポートする",
"5b2e6fe3c8": "個別にインポート",
"cf9d382ca1": "インポート",
"220dd32d83": "スキャン中...",
- "fb33359f69": "これはモノレポですか?",
- "d75170194e": "モノレポ、または一緒に扱うべきプロジェクトの場合は、グループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。",
+ "fb33359f69": "これらのリポジトリをグループ化しますか?",
+ "d75170194e": "これらのプロジェクトをまとめたい場合(モノレポでも、関連するリポジトリの集まりでも)に選択します。Orca がグループ化し、親フォルダーから作業できるようにします。",
"39d51212cc": "グループ名",
"aa0247680d": "いいえ、個別にインポート",
- "a0bc4d1f8e": "グループとしてインポート",
+ "a0bc4d1f8e": "はい、グループとしてインポート",
"8401a7a0d0": "1 個の repos",
"d4f1df62ef": "{{value0}} 個の repos",
"b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。",
- "24eda6c8b2": "スキャン中... {{value0}}",
- "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
- "e907ec8935": "What is a monorepo name?"
+ "24eda6c8b2": "スキャン中... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "スキャンの停止",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 0a0594501b5..d452e81de2c 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -3359,26 +3359,21 @@
"496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.",
"a32bef9516": "스캔 중지",
"2f8298f3c3": "스캔 중지",
- "c157f31a95": "그룹으로 가져오기",
- "40199ef7b3": "그룹 이름",
- "787412361a": "그룹 이름은 무엇입니까?",
"5f857ba8e6": "~에",
"4df0d08cc5": "발견됨",
"8db50afe1a": "폴더에서 repos 가져오기",
"5b2e6fe3c8": "별도로 가져오기",
"cf9d382ca1": "가져오기",
"220dd32d83": "스캔 중...",
- "fb33359f69": "이 폴더가 모노 repo 인가요?",
- "d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
+ "fb33359f69": "이 저장소들을 그룹으로 묶을까요?",
+ "d75170194e": "이 프로젝트들이 함께 속한 경우(모노레포이거나 단순히 관련된 저장소 모음) 이 옵션을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"39d51212cc": "그룹 이름",
"aa0247680d": "아니요, 별도로 가져오기",
- "a0bc4d1f8e": "그룹으로 가져오기",
+ "a0bc4d1f8e": "예, 그룹으로 가져오기",
"8401a7a0d0": "repos 1개",
"d4f1df62ef": "repos {{value0}}개",
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
- "24eda6c8b2": "스캔 중... {{value0}}",
- "b20bb7c24f": "이 리포지토리들을 하나의 그룹으로 묶어 둡니다. 마이크로서비스처럼 서로 관련된 리포지토리에 적합합니다.",
- "e907ec8935": "모노 repo 이름이란?"
+ "24eda6c8b2": "스캔 중... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "스캔 중지",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b67d1db3bf5..0c7753af883 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -3358,26 +3358,21 @@
"496f68cf8c": "扫描存储库。单击停止。",
"a32bef9516": "停止扫描",
"2f8298f3c3": "停止扫描",
- "c157f31a95": "作为组导入",
- "40199ef7b3": "团体名称",
- "787412361a": "什么是群组名称?",
"5f857ba8e6": "在",
"4df0d08cc5": "成立",
"8db50afe1a": "从文件夹导入存储库",
"5b2e6fe3c8": "单独导入",
"cf9d382ca1": "导入",
"220dd32d83": "扫描...",
- "fb33359f69": "这是 Monorepo 吗?",
- "d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。",
+ "fb33359f69": "将这些仓库分组吗?",
+ "d75170194e": "如果这些项目应放在一起(可以是 Monorepo,也可以只是一组相关仓库),请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。",
"39d51212cc": "组名称",
"aa0247680d": "否,单独导入",
- "a0bc4d1f8e": "作为组导入",
+ "a0bc4d1f8e": "是,作为组导入",
"8401a7a0d0": "1 个仓库",
"d4f1df62ef": "{{value0}} 个仓库",
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
- "24eda6c8b2": "正在扫描... {{value0}}",
- "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
- "e907ec8935": "What is a monorepo name?"
+ "24eda6c8b2": "正在扫描... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "停止扫描",
diff --git a/tests/e2e/folder-setup-shallow-priority.spec.ts b/tests/e2e/folder-setup-shallow-priority.spec.ts
index 96fe04580a1..b970a29ba8f 100644
--- a/tests/e2e/folder-setup-shallow-priority.spec.ts
+++ b/tests/e2e/folder-setup-shallow-priority.spec.ts
@@ -8,7 +8,7 @@ import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
const tempRoots: string[] = []
-const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo'
+const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group'
function initializeGitRepo(repoPath: string): void {
mkdirSync(repoPath, { recursive: true })
@@ -133,11 +133,11 @@ async function installCancellableNestedScanMock(
}, scan)
}
-function getImportAsMonorepoButton(importDialog: Locator): Locator {
+function getImportAsGroupButton(importDialog: Locator): Locator {
// Why: this test should fail on import-dialog copy drift instead of falling
// back to a retired accessible label.
return importDialog.getByRole('button', {
- name: IMPORT_AS_MONOREPO_BUTTON_NAME,
+ name: IMPORT_AS_GROUP_BUTTON_NAME,
exact: true
})
}
@@ -177,7 +177,7 @@ test('prioritizes shallow sibling repositories in a bounded nested scan', async
.filter({ hasText: 'z-web-client' })
.locator('input[type="checkbox"]')
.check()
- await getImportAsMonorepoButton(importDialog).click()
+ await getImportAsGroupButton(importDialog).click()
await expect
.poll(
@@ -257,13 +257,13 @@ test('can stop a nested repo scan and import repositories found so far', async (
name: /Import repositories from folder/i
})
await expect(importDialog.getByText(/Scanning\.\.\.\s*Found 1 repository in/)).toBeVisible()
- await expect(getImportAsMonorepoButton(importDialog)).toBeDisabled()
+ await expect(getImportAsGroupButton(importDialog)).toBeDisabled()
await importDialog.getByRole('button', { name: /Stop scan/i }).click()
await expect(importDialog.getByText('Scan stopped early.')).toBeVisible()
await expect(importDialog.getByText(/Found 1 repository in/)).toBeVisible()
- await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled()
+ await expect(getImportAsGroupButton(importDialog)).toBeEnabled()
- await getImportAsMonorepoButton(importDialog).click()
+ await getImportAsGroupButton(importDialog).click()
await expect
.poll(
diff --git a/tests/e2e/folder-setup.spec.ts b/tests/e2e/folder-setup.spec.ts
index 107ce1088f2..cbabf8e0bbe 100644
--- a/tests/e2e/folder-setup.spec.ts
+++ b/tests/e2e/folder-setup.spec.ts
@@ -7,7 +7,7 @@ import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { ElectronApplication, Locator } from '@stablyai/playwright-test'
-const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo'
+const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group'
const tempRoots: string[] = []
@@ -95,11 +95,11 @@ async function chooseFolderInNativeDialog(
}, folderPath)
}
-function getImportAsMonorepoButton(importDialog: Locator): Locator {
+function getImportAsGroupButton(importDialog: Locator): Locator {
// Why: the current accessible action is intentional; accepting retired
// labels would hide deterministic dialog copy drift.
return importDialog.getByRole('button', {
- name: IMPORT_AS_MONOREPO_BUTTON_NAME,
+ name: IMPORT_AS_GROUP_BUTTON_NAME,
exact: true
})
}
@@ -129,8 +129,8 @@ test.describe('Folder setup', () => {
).toBeVisible()
await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible()
await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible()
- await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled()
- await getImportAsMonorepoButton(importDialog).click()
+ await expect(getImportAsGroupButton(importDialog)).toBeEnabled()
+ await getImportAsGroupButton(importDialog).click()
await expect
.poll(
@@ -217,7 +217,7 @@ test.describe('Folder setup', () => {
.locator('input[type="checkbox"]')
.check()
}
- await getImportAsMonorepoButton(importDialog).click()
+ await getImportAsGroupButton(importDialog).click()
await expect
.poll(
From 7c9943959e87abd23fc2dc24e194c4ded0bbbc7c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 23 Jun 2026 21:05:06 -0700
Subject: [PATCH 17/18] Prevent accidental tab drags when clicking (#6210)
---
.../tab-group/useTabDragSplit.test.ts | 17 +++++++++++++++++
.../src/components/tab-group/useTabDragSplit.ts | 10 +++++++++-
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts
index 6a1efe52481..453096115b0 100644
--- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts
+++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts
@@ -10,6 +10,8 @@ import type { TabDragItemData } from './useTabDragSplit'
import {
canDropTabForPaneColumnSplit,
canDropTabIntoPaneBody,
+ getTabDragActivationDistance,
+ TAB_DRAG_ACTIVATION_DISTANCE_PX,
useTabDragSplit
} from './useTabDragSplit'
@@ -177,6 +179,21 @@ afterEach(() => {
vi.clearAllMocks()
})
+describe('tab drag activation distance', () => {
+ it('uses the named threshold for enabled tab drags', () => {
+ expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBe(12)
+ expect(getTabDragActivationDistance(true)).toBe(TAB_DRAG_ACTIVATION_DISTANCE_PX)
+ })
+
+ it('keeps enabled tab drags above the old overly-sensitive distance', () => {
+ expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBeGreaterThan(5)
+ })
+
+ it('uses an impossible activation distance when tab dragging is disabled', () => {
+ expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER)
+ })
+})
+
describe('canDropTabIntoPaneBody', () => {
it('rejects pane-body drops that would split a single tab onto itself', () => {
expect(
diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts
index 83d6b598163..8515942da7f 100644
--- a/src/renderer/src/components/tab-group/useTabDragSplit.ts
+++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts
@@ -45,6 +45,10 @@ export type { HoveredTabInsertion }
export type TabDropZone = 'center' | TabSplitDirection
+// Why: tab activation waits for pointerup, so dnd-kit needs enough movement
+// tolerance to avoid treating ordinary click jitter as an intentional drag.
+export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12
+
export type TabDragItemData = {
kind: 'tab'
worktreeId: string
@@ -145,6 +149,10 @@ export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier {
return `tab-group-pane-body:${groupId}`
}
+export function getTabDragActivationDistance(enabled: boolean): number {
+ return enabled ? TAB_DRAG_ACTIVATION_DISTANCE_PX : Number.MAX_SAFE_INTEGER
+}
+
export function useTabDragSplit({
worktreeId,
enabled = true
@@ -187,7 +195,7 @@ export function useTabDragSplit({
// the sensors array into a useEffect dependency list — changing its
// length between renders violates React's rules of hooks.
const pointerSensor = useSensor(PointerSensor, {
- activationConstraint: { distance: enabled ? 5 : Number.MAX_SAFE_INTEGER }
+ activationConstraint: { distance: getTabDragActivationDistance(enabled) }
})
const sensors = useSensors(pointerSensor)
From 1b6e65c4efe867744432a4e19271890fe8cd83c0 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Tue, 23 Jun 2026 21:24:13 -0700
Subject: [PATCH 18/18] Fix terminal paste cancellation on transient blur
(#6232)
Fix terminal paste cancellation on transient blur
---
.../terminal-paste-target-state.test.ts | 60 +++++++++++
.../terminal-paste-target-state.ts | 32 +++++-
tests/e2e/terminal-paste-ownership.spec.ts | 101 +++++++++++++++++-
3 files changed, 187 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts
index 64a78e1ed7e..4d35ef7cfbe 100644
--- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts
@@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element {
} as unknown as Element
}
+function makePaneContainerFor(acceptedElements: readonly Element[]): Element {
+ return {
+ contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element))
+ } as unknown as Element
+}
+
+function makeElement(tagName: string, classNames: string[] = []): Element {
+ return {
+ tagName,
+ classList: {
+ contains: (className: string) => classNames.includes(className)
+ }
+ } as unknown as Element
+}
+
describe('terminal paste target state', () => {
it('accepts the same mounted pane, transport, and PTY identity', () => {
const transport = makeTransport()
@@ -128,6 +143,51 @@ describe('terminal paste target state', () => {
).toBe(false)
})
+ it('keeps keyboard-owned paste current across transient document blur', () => {
+ const terminalInput = {} as Element
+ const body = makeElement('body')
+ const paneContainer = makePaneContainer(terminalInput)
+
+ expect(
+ isTerminalPanePasteFocusCurrent({
+ requireSameFocusedElement: true,
+ activeElementAtDispatch: terminalInput,
+ paneContainer,
+ activeElement: body
+ })
+ ).toBe(true)
+ })
+
+ it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => {
+ const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
+ const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea'])
+ const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput])
+
+ expect(
+ isTerminalPanePasteFocusCurrent({
+ requireSameFocusedElement: true,
+ activeElementAtDispatch: originalTerminalInput,
+ paneContainer,
+ activeElement: replacementTerminalInput
+ })
+ ).toBe(true)
+ })
+
+ it('rejects keyboard-owned paste when focus moves to another control in the pane', () => {
+ const terminalInput = makeElement('textarea', ['xterm-helper-textarea'])
+ const searchInput = makeElement('input')
+ const paneContainer = makePaneContainerFor([terminalInput, searchInput])
+
+ expect(
+ isTerminalPanePasteFocusCurrent({
+ requireSameFocusedElement: true,
+ activeElementAtDispatch: terminalInput,
+ paneContainer,
+ activeElement: searchInput
+ })
+ ).toBe(false)
+ })
+
it('does not require focus continuity for programmatic terminal paste', () => {
const terminalInput = {} as Element
const otherInput = {} as Element
diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
index 42bf8cc07bc..1f42adfaae9 100644
--- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
@@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({
if (!requireSameFocusedElement || activeElementAtDispatch === null) {
return true
}
- // Why: clipboard reads are async, so focus may leave the terminal before
- // execution. In that case the stale terminal must not receive the payload.
- return (
- activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch)
- )
+ if (!paneContainer.contains(activeElementAtDispatch)) {
+ return false
+ }
+ if (activeElement === activeElementAtDispatch) {
+ return true
+ }
+ // Why: macOS dictation and clipboard permission handoffs can transiently
+ // blur xterm to body, and xterm may replace its helper textarea mid-paste.
+ if (isInertDocumentFocus(activeElement)) {
+ return true
+ }
+ if (activeElement === paneContainer) {
+ return true
+ }
+ return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement)
+}
+
+function isInertDocumentFocus(element: Element | null): boolean {
+ if (!element) {
+ return true
+ }
+ const tagName = element.tagName?.toUpperCase()
+ return tagName === 'BODY' || tagName === 'HTML'
+}
+
+function isXtermHelperTextarea(element: Element | null): boolean {
+ return element?.classList?.contains('xterm-helper-textarea') === true
}
diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts
index 998aa844147..3e95b02f123 100644
--- a/tests/e2e/terminal-paste-ownership.spec.ts
+++ b/tests/e2e/terminal-paste-ownership.spec.ts
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
-import type { Page } from '@stablyai/playwright-test'
+import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
focusActiveTerminalInput,
@@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise
{
await page.mouse.click(point.x, point.y, { button: 'right' })
}
+async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise {
+ await app.evaluate(({ BrowserWindow, ipcMain }) => {
+ type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown
+ const global = globalThis as unknown as {
+ __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
+ }
+ const invokeHandlers = (
+ ipcMain as unknown as {
+ _invokeHandlers?: Map
+ }
+ )._invokeHandlers
+ const handler = invokeHandlers?.get('clipboard:readText')
+ if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) {
+ return
+ }
+ global.__orcaOriginalClipboardReadTextHandler = handler
+ invokeHandlers.set('clipboard:readText', async (event, ...args) => {
+ const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed())
+ await Promise.all(
+ windows.map((window) =>
+ window.webContents
+ .executeJavaScript(
+ `
+ (document.activeElement && document.activeElement.blur && document.activeElement.blur());
+ document.body.tabIndex = -1;
+ document.body.focus();
+ `
+ )
+ .catch(() => undefined)
+ )
+ )
+ // Why: reproduce the focus churn window before the async clipboard read resolves.
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ return global.__orcaOriginalClipboardReadTextHandler!(event, ...args)
+ })
+ })
+}
+
+async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise {
+ await app.evaluate(({ ipcMain }) => {
+ type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown
+ const global = globalThis as unknown as {
+ __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler
+ }
+ const invokeHandlers = (
+ ipcMain as unknown as {
+ _invokeHandlers?: Map
+ }
+ )._invokeHandlers
+ if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) {
+ invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler)
+ delete global.__orcaOriginalClipboardReadTextHandler
+ }
+ })
+}
+
async function openTerminalContextMenu(page: Page): Promise {
const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows'))
const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac'))
@@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => {
}
})
+ test('keyboard paste survives transient terminal blur during clipboard read', async ({
+ electronApp,
+ orcaPage,
+ testRepoPath
+ }) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ await ensureTerminalVisible(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 30_000)
+ await installTerminalPtyWriteSpy(electronApp)
+
+ const ptyId = await waitForActivePanePtyId(orcaPage)
+ const runId = randomUUID()
+ const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`)
+ writeFileSync(scriptPath, pasteEchoScript(runId))
+ let scriptStarted = false
+
+ try {
+ await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
+ scriptStarted = true
+ await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000)
+
+ const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}`
+ const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
+ await clearTerminalPtyWriteLog(electronApp)
+ await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload)
+ await focusActiveTerminalInput(orcaPage)
+ await installClipboardReadTerminalBlurRepro(electronApp)
+
+ await orcaPage.keyboard.press(keyboardPasteChords()[0])
+ await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000)
+
+ const writes = (await readTerminalPtyWrites(electronApp)).join('')
+ expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1)
+ } finally {
+ await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined)
+ if (scriptStarted) {
+ await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
+ }
+ rmSync(scriptPath, { force: true })
+ }
+ })
+
test('terminal context-menu Paste sends clipboard text exactly once', async ({
electronApp,
orcaPage,