From 24b8fcc91834888c8a6efec403ee1a87ad7281da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:12:38 -0700 Subject: [PATCH 01/29] fix(mobile): restore the last-open tab when returning to a worktree on mobile (#9801) * fix(mobile): persist per-device tab selection so worktree return restores the last open tab A phone's tab selection lived only in the host's in-memory ClientSessionTabSelectionStore. Any host restart wiped it, and the per-device projection then fell back to deterministic topology, so returning to a worktree on mobile always landed on the first tab instead of the tab last opened on the phone. Persist the per-device selections in the Store (keyed deviceId -> worktreeId), hydrate them when the runtime constructs, and guard projection so an early empty snapshot after restart cannot wipe a hydrated selection before tabs arrive. Selections are pruned with the worktree/repo and on device revoke, and malformed persisted payloads degrade to empty instead of throwing. * fix(mobile): harden persisted tab selection cleanup * fix(mobile): preserve tab selection across worktree rename --- src/main/persistence.test.ts | 120 +++++++++++++++ src/main/persistence.ts | 41 +++++ ...lient-session-tab-selection-persistence.ts | 61 ++++++++ .../client-session-tab-selection.test.ts | 145 ++++++++++++++++++ .../runtime/client-session-tab-selection.ts | 98 +++++++++++- ...t-navigation-isolation.integration.test.ts | 40 +++++ src/main/runtime/orca-runtime.ts | 11 ++ src/shared/types.ts | 15 ++ 8 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/client-session-tab-selection-persistence.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fb65a4cf8ea..5124b34c2da 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9703,6 +9703,110 @@ describe('Store', () => { // ── Live Claude PTY session ids (STA-1246) ───────────────────────── + describe('mobileClientTabSelectionsByDeviceId', () => { + it('persists device tab selections across reloads and drops malformed payloads', async () => { + const store = await createStore() + store.setMobileClientTabSelections({ + 'device-a': { + 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } + } + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({ + activeTabId: 'tab-1', + activeGroupId: 'g1', + activeTabIdByGroupId: {} + }) + + writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } }) + const corrupted = await createStore() + expect(corrupted.getMobileClientTabSelections()).toEqual({}) + }) + + it('prunes selections for a removed repo worktree', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + store.setMobileClientTabSelections({ + 'device-a': { + 'r1::/tmp/wt': { + activeTabId: 'tab-1', + activeGroupId: null, + activeTabIdByGroupId: {} + }, + 'other-repo::/tmp/wt': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + }) + + store.removeProject('r1') + store.flush() + + expect(store.getMobileClientTabSelections()['device-a']).toEqual({ + 'other-repo::/tmp/wt': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {} + } + }) + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({ + 'other-repo::/tmp/wt': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {} + } + }) + }) + + it('prunes selections when a folder workspace is removed directly or with its group', async () => { + const store = await createStore() + const directGroup = store.createProjectGroup({ + name: 'Direct', + parentPath: '/tmp/direct', + createdFrom: 'manual' + }) + const directWorkspace = store.createFolderWorkspace({ + projectGroupId: directGroup.id, + name: 'Direct workspace' + }) + const cascadeGroup = store.createProjectGroup({ + name: 'Cascade', + parentPath: '/tmp/cascade', + createdFrom: 'manual' + }) + const cascadeWorkspace = store.createFolderWorkspace({ + projectGroupId: cascadeGroup.id, + name: 'Cascade workspace' + }) + store.setMobileClientTabSelections({ + 'device-a': { + [folderWorkspaceKey(directWorkspace.id)]: { + activeTabId: 'tab-direct', + activeGroupId: null, + activeTabIdByGroupId: {} + }, + [folderWorkspaceKey(cascadeWorkspace.id)]: { + activeTabId: 'tab-cascade', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + }) + + store.removeFolderWorkspace(directWorkspace.id) + store.deleteProjectGroup(cascadeGroup.id) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()).toEqual({}) + }) + }) + describe('claudeLivePtySessionIds', () => { it('persists added ids across reloads and removes them durably', async () => { const store = await createStore() @@ -10360,6 +10464,22 @@ describe('Store.migrateWorktreeIdentity', () => { expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW) }) + it('moves persisted mobile selections across reloads', async () => { + const store = await createStore() + store.setMobileClientTabSelections({ + 'device-a': { + [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } + } + }) + + store.migrateWorktreeIdentity(OLD, NEW) + store.flush() + + expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined() + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1') + }) + it('accumulates prior ids across chained renames', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ebb0ebacca6..3eb82817212 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -48,6 +48,7 @@ import type { ProjectGroup, FolderWorkspace, SparsePreset, + PersistedMobileClientTabSelections, WorktreeMeta, WorktreeLineage, WorkspaceLineage, @@ -77,6 +78,7 @@ import { } from '../shared/task-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' +import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, @@ -3034,6 +3036,9 @@ export class Store { normalizedProjectGroups ), worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( parsed.workspaceLineageByChildKey ), @@ -3910,8 +3915,10 @@ export class Store { ? { ...repo, projectGroupId: null } : repo ) + const removedFolderWorkspaceKeys = new Set() for (const workspace of this.state.folderWorkspaces ?? []) { if (deletedGroupIds.has(workspace.projectGroupId)) { + removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) this.state.workspaceSession = removeWorkspaceSessionOwner( this.state.workspaceSession, folderWorkspaceKey(workspace.id) @@ -3922,6 +3929,7 @@ export class Store { this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( (workspace) => !deletedGroupIds.has(workspace.projectGroupId) ) + this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) this.scheduleSave() return true } @@ -4071,6 +4079,7 @@ export class Store { folderWorkspaceKey(id) )! this.removeWorkspaceLineageForFolderParent(id) + this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) this.scheduleSave() return true } @@ -4249,6 +4258,22 @@ export class Store { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } + this.pruneMobileClientTabSelections(belongsToHost) + } + + private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + this.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } } updateRepo( @@ -4488,6 +4513,17 @@ export class Store { // ── Sparse Presets ───────────────────────────────────────────────── + // ── Mobile client tab selections ────────────────────────────────── + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return this.state.mobileClientTabSelectionsByDeviceId ?? {} + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this.state.mobileClientTabSelectionsByDeviceId = next + this.scheduleSave() + } + getSparsePresets(repoId: string): SparsePreset[] { return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => left.name.localeCompare(right.name) @@ -5035,6 +5071,11 @@ export class Store { for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { changed = migrateSession(session) || changed } + for (const selectionsByWorktree of Object.values( + this.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + changed = moveKey(selectionsByWorktree) || changed + } const showDotfiles = this.state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts new file mode 100644 index 00000000000..358144cb49d --- /dev/null +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -0,0 +1,61 @@ +import type { + PersistedMobileClientTabSelection, + PersistedMobileClientTabSelections +} from '../../shared/types' + +function normalizeClientSessionTabSelection( + raw: unknown +): PersistedMobileClientTabSelection | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return null + } + const candidate = raw as Partial + const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null + const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null + const activeTabIdByGroupId: Record = {} + if ( + typeof candidate.activeTabIdByGroupId === 'object' && + candidate.activeTabIdByGroupId && + !Array.isArray(candidate.activeTabIdByGroupId) + ) { + for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) { + if (typeof tabId === 'string') { + activeTabIdByGroupId[groupId] = tabId + } + } + } + if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) { + return null + } + return { activeTabId, activeGroupId, activeTabIdByGroupId } +} + +// Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw. +export function normalizePersistedMobileClientTabSelections( + raw: unknown +): PersistedMobileClientTabSelections { + const normalized: PersistedMobileClientTabSelections = {} + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return normalized + } + for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) { + if ( + typeof selectionsByWorktree !== 'object' || + selectionsByWorktree === null || + Array.isArray(selectionsByWorktree) + ) { + continue + } + const entries: Record = {} + for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { + const normalizedSelection = normalizeClientSessionTabSelection(selection) + if (normalizedSelection) { + entries[worktreeId] = normalizedSelection + } + } + if (Object.keys(entries).length > 0) { + normalized[clientNavigationId] = entries + } + } + return normalized +} diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts index c75ffd2ac78..b0db68c5ca5 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { PersistedMobileClientTabSelections } from '../../shared/types' import { activateClientSessionTabSelection, ClientSessionTabSelectionStore, deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' +import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult { const tabs = [ @@ -138,4 +140,147 @@ describe('client session-tab selection', () => { expect(projected.activeGroupId).toBe('group-left') expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a') }) + + it('persists activations and restores them across a store rebuild (host restart)', () => { + const persisted: PersistedMobileClientTabSelections[] = [] + const store = new ClientSessionTabSelectionStore() + store.setPersistListener((state) => persisted.push(state)) + + store.activate(snapshot(), 'device-a', 'browser-unified') + + expect(persisted).toHaveLength(1) + expect(persisted[0]?.['device-a']?.['wt-1']?.activeTabId).toBe('browser-unified') + + const restarted = new ClientSessionTabSelectionStore() + restarted.hydrate(persisted[0]!) + const projected = restarted.project(snapshot(), 'device-a') + + expect(projected.activeTabId).toBe('browser-unified') + expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('browser-unified') + expect(restarted.project(snapshot(), 'device-b').activeTabId).toBe('terminal-a::leaf-a') + }) + + it('persists forgetClient and forgetWorktree removals', () => { + const persisted: PersistedMobileClientTabSelections[] = [] + const store = new ClientSessionTabSelectionStore() + store.activate(snapshot(), 'device-a', 'browser-unified') + store.setPersistListener((state) => persisted.push(state)) + + store.forgetWorktree('wt-1') + expect(persisted.at(-1)).toEqual({}) + + store.activate(snapshot(), 'device-a', 'browser-unified') + store.forgetClient('device-a') + expect(persisted.at(-1)).toEqual({}) + // Why: forgetting state that is already gone must not rewrite the persisted file. + const writes = persisted.length + store.forgetClient('device-a') + store.forgetWorktree('wt-1') + expect(persisted.length).toBe(writes) + }) + + it('moves persisted selections when a worktree identity changes', () => { + const persisted: PersistedMobileClientTabSelections[] = [] + const store = new ClientSessionTabSelectionStore() + store.activate(snapshot(), 'device-a', 'browser-unified') + store.setPersistListener((state) => persisted.push(state)) + + store.migrateWorktree('wt-1', 'wt-renamed') + + expect(persisted).toEqual([ + { + 'device-a': { + 'wt-renamed': { + activeTabId: 'browser-unified', + activeGroupId: 'group-right', + activeTabIdByGroupId: { + 'group-left': 'terminal-a', + 'group-right': 'browser-unified' + } + } + } + } + ]) + expect(store.project({ ...snapshot(), worktree: 'wt-renamed' }, 'device-a').activeTabId).toBe( + 'browser-unified' + ) + }) + + it('does not persist topology-only projections from unrelated worktrees', () => { + const persisted: PersistedMobileClientTabSelections[] = [] + const store = new ClientSessionTabSelectionStore() + store.setPersistListener((state) => persisted.push(state)) + + store.project({ ...snapshot(), worktree: 'listed-only' }, 'device-a') + store.activate(snapshot(), 'device-a', 'browser-unified') + + expect(persisted).toEqual([ + { + 'device-a': { + 'wt-1': { + activeTabId: 'browser-unified', + activeGroupId: 'group-right', + activeTabIdByGroupId: { 'group-right': 'browser-unified' } + } + } + } + ]) + + store.forgetWorktree('listed-only') + expect(persisted).toHaveLength(1) + }) + + it('does not let an empty snapshot wipe a hydrated selection before tabs arrive', () => { + const store = new ClientSessionTabSelectionStore() + store.hydrate({ + 'device-a': { + 'wt-1': { activeTabId: 'browser-unified', activeGroupId: null, activeTabIdByGroupId: {} } + } + }) + + const empty = { + ...snapshot(), + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabGroups: [], + tabs: [] + } + expect(store.project(empty, 'device-a').activeTabId).toBeNull() + + expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified') + }) + + it('drops malformed persisted payloads instead of hydrating them', () => { + expect( + normalizePersistedMobileClientTabSelections({ + 'device-a': { + 'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } }, + 'wt-bad': { activeTabId: 42, activeGroupId: null, activeTabIdByGroupId: { g: 7 } } + }, + 'device-bad': 'nope', + 'device-empty': {} + }) + ).toEqual({ + 'device-a': { + 'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } } + } + }) + expect(normalizePersistedMobileClientTabSelections(null)).toEqual({}) + expect(normalizePersistedMobileClientTabSelections('garbage')).toEqual({}) + expect(normalizePersistedMobileClientTabSelections([{ 'wt-1': {} }])).toEqual({}) + expect( + normalizePersistedMobileClientTabSelections({ + 'device-array': [{ activeTabId: 'tab-1' }], + 'device-selection-array': { 'wt-1': ['tab-1'] }, + 'device-group-array': { + 'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: ['tab-1'] } + } + }) + ).toEqual({ + 'device-group-array': { + 'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } + } + }) + }) }) diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index d3cb60e67b7..7c8f84f88d2 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -2,6 +2,8 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { PersistedMobileClientTabSelections } from '../../shared/types' +import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' export type ClientSessionTabSelection = { activeTabId: string | null @@ -12,6 +14,8 @@ export type ClientSessionTabSelection = { type StoredClientSessionTabSelection = { selection: ClientSessionTabSelection revision: number + // Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk. + shouldPersist: boolean } function emptyClientSessionTabSelection(): ClientSessionTabSelection { @@ -126,6 +130,43 @@ export function projectClientSessionTabSelection( export class ClientSessionTabSelectionStore { private statesByClient = new Map>() + private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null + + // Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback). + hydrate(persisted: PersistedMobileClientTabSelections): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + normalizePersistedMobileClientTabSelections(persisted) + )) { + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) + for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { + statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true }) + } + } + } + + setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void { + this.persistListener = listener + } + + serialize(): PersistedMobileClientTabSelections { + const persisted: PersistedMobileClientTabSelections = {} + for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + const entries: Record = {} + for (const [worktreeId, state] of statesByWorktree) { + if (state.shouldPersist) { + entries[worktreeId] = state.selection + } + } + if (Object.keys(entries).length > 0) { + persisted[clientNavigationId] = entries + } + } + return persisted + } + + private persistNow(): void { + this.persistListener?.(this.serialize()) + } private getStatesByWorktree( clientNavigationId: string @@ -149,12 +190,22 @@ export class ClientSessionTabSelectionStore { const state = statesByWorktree.get(snapshot.worktree) ?? { // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. selection: emptyClientSessionTabSelection(), - revision: 0 + revision: 0, + shouldPersist: false + } + if (snapshot.tabs.length === 0) { + // Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive. + return { + ...snapshot, + publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`, + snapshotVersion: snapshot.snapshotVersion + state.revision + } } const projected = projectClientSessionTabSelection(snapshot, state.selection) statesByWorktree.set(snapshot.worktree, { selection: projected.selection, - revision: state.revision + revision: state.revision, + shouldPersist: state.shouldPersist }) return { ...projected.snapshot, @@ -171,25 +222,60 @@ export class ClientSessionTabSelectionStore { const statesByWorktree = this.getStatesByWorktree(clientNavigationId) const state = statesByWorktree.get(snapshot.worktree) ?? { selection: emptyClientSessionTabSelection(), - revision: 0 + revision: 0, + shouldPersist: false } + const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId) statesByWorktree.set(snapshot.worktree, { - selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId), - revision: state.revision + 1 + selection: nextSelection, + revision: state.revision + 1, + shouldPersist: true }) + this.persistNow() return this.project(snapshot, clientNavigationId) } forgetClient(clientNavigationId: string): void { - this.statesByClient.delete(clientNavigationId) + const statesByWorktree = this.statesByClient.get(clientNavigationId) + const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some( + (state) => state.shouldPersist + ) + if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) { + this.persistNow() + } + } + + migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void { + if (oldWorktreeId === newWorktreeId) { + return + } + let changed = false + for (const statesByWorktree of this.statesByClient.values()) { + const state = statesByWorktree.get(oldWorktreeId) + if (!state) { + continue + } + statesByWorktree.set(newWorktreeId, state) + statesByWorktree.delete(oldWorktreeId) + changed = state.shouldPersist || changed + } + if (changed) { + this.persistNow() + } } forgetWorktree(worktreeId: string): void { + let changed = false for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + const state = statesByWorktree.get(worktreeId) + changed = Boolean(state?.shouldPersist) || changed statesByWorktree.delete(worktreeId) if (statesByWorktree.size === 0) { this.statesByClient.delete(clientNavigationId) } } + if (changed) { + this.persistNow() + } } } diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 3c918f7b4b2..d475d6aa08b 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import { parsePairingCode } from '../../shared/pairing' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { PersistedMobileClientTabSelections } from '../../shared/types' import { OrcaRuntimeService } from './orca-runtime' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { OrcaRuntimeRpcServer } from './runtime-rpc' @@ -578,4 +579,43 @@ describe('paired runtime navigation isolation', () => { expect(serverOne.hostSelections.tabId).toBe('host-tab') expect(serverTwo.hostSelections.tabId).toBe('host-tab') }) + + it('restores a device tab selection after a runtime restart', async () => { + const persisted: { state: PersistedMobileClientTabSelections } = { state: {} } + const makeStoreWithSelections = () => ({ + ...makeStore(), + getMobileClientTabSelections: () => persisted.state, + setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => { + persisted.state = next + } + }) + + const first = new OrcaRuntimeService(makeStoreWithSelections() as never) + first.attachWindow(1) + first.markGraphReady(1) + seedSessionTabs(first) + await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, { + notifyClients: false, + clientNavigationId: 'device-a', + navigation: 'caller' + }) + expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab') + + const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never) + restarted.attachWindow(1) + restarted.markGraphReady(1) + seedSessionTabs(restarted) + const remembered = await restarted.listMobileSessionTabs( + `id:${SESSION_WORKTREE_ID}`, + 'device-a' + ) + expect(remembered.activeTabId).toBe('client-a-tab') + expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab') + // Why: an unknown device must still start from deterministic topology, not inherit another device's restored state. + const freshDevice = await restarted.listMobileSessionTabs( + `id:${SESSION_WORKTREE_ID}`, + 'device-b' + ) + expect(freshDevice.activeTabId).toBe('host-tab') + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0706ec19b59..5c25ce81792 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -952,6 +952,8 @@ type RuntimeStore = { deleteAutomation?: Store['deleteAutomation'] getSparsePresets?: Store['getSparsePresets'] saveSparsePreset?: Store['saveSparsePreset'] + getMobileClientTabSelections?: Store['getMobileClientTabSelections'] + setMobileClientTabSelections?: Store['setMobileClientTabSelections'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -2879,6 +2881,14 @@ export class OrcaRuntimeService { } ) { this.store = store + // Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return. + const persistedClientTabSelections = store?.getMobileClientTabSelections?.() + if (persistedClientTabSelections) { + this.clientSessionTabSelections.hydrate(persistedClientTabSelections) + } + this.clientSessionTabSelections.setPersistListener((state) => { + this.store?.setMobileClientTabSelections?.(state) + }) if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) @@ -23946,6 +23956,7 @@ export class OrcaRuntimeService { /** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */ notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void { + this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repoId) this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 437f92d6535..bc68a337570 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3419,6 +3419,19 @@ export type LegacyPaneKeyAliasEntry = { updatedAt: number } +/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */ +export type PersistedMobileClientTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> +} + +/** deviceId → worktreeId → selection. */ +export type PersistedMobileClientTabSelections = Record< + string, + Record +> + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number @@ -3429,6 +3442,8 @@ export type PersistedState = { folderWorkspaces: FolderWorkspace[] /** Sparse-checkout presets keyed by repoId. */ sparsePresetsByRepo: Record + /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ + mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections worktreeMeta: Record worktreeLineageById: Record workspaceLineageByChildKey: Record From 405b9f245af24c36394e934063170c824d3c244f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:21:25 -0700 Subject: [PATCH 02/29] feat(mobile): mount ProtocolBlockScreen when protocol compat is blocked (#9780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): mount ProtocolBlockScreen when protocol compat is blocked ProtocolBlockScreen existed since PR #1440 but was never mounted: on a 'blocked' compat verdict the only output was a console.warn, so a future MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION bump would have silently shown a broken host UI instead of the update screen. Add HostProtocolGate — a choke point in app/h/_layout.tsx above every /h/[hostId] route — that consumes useHostStatusGates and replaces the blocked host's entire UI (sidebar + detail stack) with ProtocolBlockScreen. The host list and other hosts stay usable; the screen's own 'Back to hosts' escape hatch routes to '/'. Both block reasons render their respective CTAs (mobile-too-old → App Store, desktop-too-old → GitHub Releases). Compat logic stays in the src/shared mirror contract — no fork. * fix(mobile): fence incompatible host routes efficiently * fix(mobile): route Android updates to releases --- mobile/app/h/[hostId]/index.tsx | 13 +- mobile/app/h/_layout.tsx | 33 +-- .../src/components/HostProtocolGate.test.ts | 200 ++++++++++++++++++ mobile/src/components/HostProtocolGate.tsx | 59 ++++++ mobile/src/components/ProtocolBlockScreen.tsx | 27 +-- mobile/src/transport/host-status-gates.ts | 30 ++- 6 files changed, 316 insertions(+), 46 deletions(-) create mode 100644 mobile/src/components/HostProtocolGate.test.ts create mode 100644 mobile/src/components/HostProtocolGate.tsx diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 5a6760508f2..860106aa1b6 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -61,7 +61,7 @@ import { buildWorktreeNavigationActions } from '../../../src/agent-history/workt import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' -import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' +import { useHostProtocolGates } from '../../../src/components/HostProtocolGate' import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner' import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' @@ -70,7 +70,6 @@ import { setCachedRepos } from '../../../src/cache/repo-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { leaveHostRoute } from '../../../src/host-route-exit' -import { useHostStatusGates } from '../../../src/transport/host-status-gates' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { createInitialHostRouteActionState, @@ -176,11 +175,7 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) - const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({ - hostId, - client, - connState - }) + const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates() const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -780,10 +775,6 @@ export function HostScreen({ ) } - if (compatVerdict.kind === 'blocked') { - return - } - return ( diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index f832b7c3967..7f77c33b648 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -10,6 +10,7 @@ import { loadHostSidebarWidth, saveHostSidebarWidth } from '../../src/storage/preferences' +import { HostProtocolGate } from '../../src/components/HostProtocolGate' import { HostScreen } from './[hostId]/index' // Keep at least this much room for the detail pane when resizing the sidebar. @@ -138,23 +139,25 @@ export default function HostGroupLayout() { // changes so a fold/rotation doesn't remount the navigator and reset the // navigation stack — only the sidebar pane toggles in and out. return ( - - {showSidebar && sidebarOpen ? ( - - - {/* Dedicated drag handle straddling the right border — see resizer note. */} - + + + {showSidebar && sidebarOpen ? ( + + + {/* Dedicated drag handle straddling the right border — see resizer note. */} + + + ) : null} + + - ) : null} - - - + ) } diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts new file mode 100644 index 00000000000..91d3b858a6a --- /dev/null +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -0,0 +1,200 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate' + +const nativeTestState = vi.hoisted(() => ({ + openUrl: vi.fn(), + platform: { OS: 'ios' as 'ios' | 'android' } +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Linking: { openURL: nativeTestState.openUrl }, + Platform: nativeTestState.platform, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('expo-router', () => ({ + router: { replace: vi.fn() } +})) + +// Why: mock only client acquisition; the gate must exercise the real +// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring. +const hostClient = vi.hoisted(() => ({ + current: { client: null as RpcClient | null, state: 'disconnected' as string } +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => hostClient.current +})) + +function clientWithStatus(result: Record): RpcClient { + return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient +} + +function GateConsumer() { + const { hostCapabilities } = useHostProtocolGates() + return createElement('GateStatus', null, hostCapabilities.join(',')) +} + +function gateElement() { + return createElement( + HostProtocolGate, + { hostId: 'host-1' }, + createElement('HostContent', null, createElement(GateConsumer)) + ) +} + +async function renderGate(): Promise { + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(gateElement()) + await Promise.resolve() + }) + return renderer as unknown as ReactTestRenderer +} + +function renderedText(renderer: ReactTestRenderer): string { + return JSON.stringify(renderer.toJSON()) +} + +describe('HostProtocolGate', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + nativeTestState.openUrl.mockClear() + nativeTestState.platform.OS = 'ios' + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('replaces the host UI with the block screen when mobile is too old', async () => { + // Why: blocked warns to console; keep test output clean without hiding other errors. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + hostClient.current = { + client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }), + state: 'connected' + } + renderer = await renderGate() + const output = renderedText(renderer) + expect(output).toContain('Update Orca Mobile') + expect(output).toContain('Open App Store') + expect(output).not.toContain('HostContent') + }) + + it('routes Android mobile updates to GitHub Releases', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + nativeTestState.platform.OS = 'android' + hostClient.current = { + client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }), + state: 'connected' + } + renderer = await renderGate() + const output = renderedText(renderer) + expect(output).toContain('Update Orca Mobile') + expect(output).toContain('Update Orca Mobile from GitHub Releases') + expect(output).toContain('Open GitHub Releases') + expect(output).not.toContain('mobile app store') + expect(output).not.toContain('HostContent') + act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress()) + expect(nativeTestState.openUrl).toHaveBeenCalledWith( + 'https://github.com/stablyai/orca/releases' + ) + }) + + it('replaces the host UI with the block screen when desktop is too old', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + hostClient.current = { + client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }), + state: 'connected' + } + renderer = await renderGate() + const output = renderedText(renderer) + expect(output).toContain('Update Orca on your computer') + expect(output).toContain('Open GitHub Releases') + expect(output).not.toContain('HostContent') + }) + + it('renders the host UI when the verdict is ok', async () => { + const client = clientWithStatus({ + protocolVersion: 5, + minCompatibleMobileVersion: 0, + capabilities: ['browser.screencast.v1'] + }) + hostClient.current = { + client, + state: 'connected' + } + renderer = await renderGate() + const output = renderedText(renderer) + expect(output).toContain('HostContent') + expect(output).toContain('browser.screencast.v1') + expect(output).not.toContain('Update Orca') + expect(client.sendRequest).toHaveBeenCalledOnce() + }) + + it('renders the host UI while the host connection is still pending', async () => { + hostClient.current = { client: null, state: 'connecting' } + renderer = await renderGate() + expect(renderedText(renderer)).toContain('HostContent') + }) + + it('does not mount host routes before a connected host passes the compatibility probe', async () => { + const client = { + sendRequest: vi.fn().mockReturnValue(new Promise(() => {})) + } as unknown as RpcClient + hostClient.current = { client, state: 'connected' } + renderer = await renderGate() + const output = renderedText(renderer) + expect(output).toContain('Checking host compatibility') + expect(output).not.toContain('HostContent') + expect(client.sendRequest).toHaveBeenCalledOnce() + }) + + it('keeps an already-validated host route mounted while reconnect status is pending', async () => { + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { protocolVersion: 5, minCompatibleMobileVersion: 0 } + }) + .mockReturnValueOnce(new Promise(() => {})) + } as unknown as RpcClient + hostClient.current = { client, state: 'connected' } + renderer = await renderGate() + + await act(async () => { + hostClient.current = { client, state: 'disconnected' } + renderer?.update(gateElement()) + }) + await act(async () => { + hostClient.current = { client, state: 'connected' } + renderer?.update(gateElement()) + await Promise.resolve() + }) + + expect(renderedText(renderer)).toContain('HostContent') + expect(client.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('fails open when a connected host cannot answer the status probe', async () => { + hostClient.current = { + client: { + sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } }) + } as unknown as RpcClient, + state: 'connected' + } + renderer = await renderGate() + expect(renderedText(renderer)).toContain('HostContent') + }) +}) diff --git a/mobile/src/components/HostProtocolGate.tsx b/mobile/src/components/HostProtocolGate.tsx new file mode 100644 index 00000000000..565784e2539 --- /dev/null +++ b/mobile/src/components/HostProtocolGate.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, useRef, type ReactNode } from 'react' +import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { useHostClient } from '../transport/client-context' +import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates' +import { colors } from '../theme/mobile-theme' +import { ProtocolBlockScreen } from './ProtocolBlockScreen' + +type Props = { + hostId: string | undefined + children: ReactNode +} + +const HostStatusGatesContext = createContext(null) + +export function useHostProtocolGates(): HostStatusGates { + const gates = useContext(HostStatusGatesContext) + if (!gates) { + throw new Error('useHostProtocolGates must be used inside ') + } + return gates +} + +// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the +// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable. +export function HostProtocolGate({ hostId, children }: Props) { + const { client, state } = useHostClient(hostId) + const gates = useHostStatusGates({ hostId, client, connState: state }) + const { compatVerdict, statusPending } = gates + const resolvedHostIdRef = useRef(null) + const hostKey = hostId ?? null + if (state === 'connected' && client && !statusPending) { + resolvedHostIdRef.current = hostKey + } + if (statusPending && resolvedHostIdRef.current !== hostKey) { + // Why: child routes may call newer RPCs on mount, so wait until compatibility is known. + return ( + + + + ) + } + if (compatVerdict.kind === 'blocked') { + return + } + // Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route. + return {children} +} + +const styles = StyleSheet.create({ + pending: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index 6f64a9075b4..ed8fc2bcddd 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -12,14 +12,13 @@ type Props = { export function ProtocolBlockScreen({ verdict }: Props) { const isMobileTooOld = verdict.reason === 'mobile-too-old' + // Why: Android APKs ship through GitHub Releases until a Play Store listing exists. const mobileUpdateTarget = Platform.OS === 'ios' ? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' } - : { label: null, url: null, storeName: 'your mobile app store' } + : { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' } const primaryAction = isMobileTooOld - ? mobileUpdateTarget.url && mobileUpdateTarget.label - ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } - : null + ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } : { label: 'Open GitHub Releases', url: RELEASES_URL } const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer' @@ -34,18 +33,14 @@ export function ProtocolBlockScreen({ verdict }: Props) { {title} {body} - {/* Why: desktop updates come from GitHub; mobile update links depend - on the native store available for this platform. */} - {primaryAction ? ( - [styles.primaryButton, pressed && styles.pressed]} - onPress={() => { - void Linking.openURL(primaryAction.url) - }} - > - {primaryAction.label} - - ) : null} + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => { + void Linking.openURL(primaryAction.url) + }} + > + {primaryAction.label} + [styles.secondaryButton, pressed && styles.pressed]} onPress={() => { diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index 7a02cc87abb..8a26f5cb6a7 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -8,6 +8,7 @@ export type HostStatusGates = { hostCapabilities: string[] floatingWorkspaceEnabled: boolean compatVerdict: CompatVerdict + statusPending: boolean } type LoadedHostStatusGates = HostStatusGates & { @@ -42,6 +43,14 @@ export function useHostStatusGates(args: { return } if (!response.ok) { + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' }, + statusPending: false + }) return } const status = (response as RpcSuccess).result as DesktopStatus & { @@ -56,7 +65,8 @@ export function useHostStatusGates(args: { client: requestClient, hostCapabilities: status.capabilities ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, - compatVerdict: verdict + compatVerdict: verdict, + statusPending: false }) if (verdict.kind === 'blocked') { // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. @@ -68,7 +78,17 @@ export function useHostStatusGates(args: { }) } } catch { - // Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden. + // Why: a transient status failure must not trap navigation; conservative feature gates remain disabled. + if (!cancelled) { + setLoaded({ + hostId, + client: requestClient, + hostCapabilities: [], + floatingWorkspaceEnabled: false, + compatVerdict: { kind: 'ok' }, + statusPending: false + }) + } } })() return () => { @@ -87,12 +107,14 @@ export function useHostStatusGates(args: { return { hostCapabilities: EMPTY_HOST_CAPABILITIES, floatingWorkspaceEnabled: false, - compatVerdict: { kind: 'ok' } + compatVerdict: { kind: 'ok' }, + statusPending: connState === 'connected' && client !== null } } return { hostCapabilities: loaded.hostCapabilities, floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled, - compatVerdict: loaded.compatVerdict + compatVerdict: loaded.compatVerdict, + statusPending: false } } From 56a31a5af0ab3d5b4585f6060fa9d601a7bd00c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:29:45 -0700 Subject: [PATCH 03/29] Show current branch in Source Control header (#9787) * feat(source-control): show current branch in header * fix(source-control): keep header focused on branch * fix(source-control): compact detached head identity * fix(source-control): make branch identity keyboard accessible * fix(source-control): keep create review in checks --- .../src/components/DetachedHeadBadge.tsx | 8 +- ...Control.hosted-review-header-link.test.tsx | 157 ------------------ .../right-sidebar/SourceControl.tsx | 39 +---- .../hosted-review-header-chrome.tsx | 74 --------- ...rce-control-create-pr-intent-state.test.ts | 43 ----- .../source-control-create-pr-intent-state.ts | 11 -- .../source-control-header-toolbar.test.tsx | 90 ++++++++++ .../source-control-header-toolbar.tsx | 121 +++++--------- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 13 files changed, 145 insertions(+), 413 deletions(-) delete mode 100644 src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx diff --git a/src/renderer/src/components/DetachedHeadBadge.tsx b/src/renderer/src/components/DetachedHeadBadge.tsx index 58a12e6395e..bc03ce2ab52 100644 --- a/src/renderer/src/components/DetachedHeadBadge.tsx +++ b/src/renderer/src/components/DetachedHeadBadge.tsx @@ -12,13 +12,15 @@ type DetachedHeadBadgeProps = { label?: 'sidebar' | 'source-control' side?: React.ComponentProps['side'] className?: string + tabIndex?: number } export function DetachedHeadBadge({ display, label = 'source-control', side = 'right', - className + className, + tabIndex }: DetachedHeadBadgeProps): React.JSX.Element { const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel @@ -27,6 +29,8 @@ export function DetachedHeadBadge({ - {visibleLabel} + {visibleLabel} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx deleted file mode 100644 index ee90c607ada..00000000000 --- a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { HostedReviewHeaderLink } from './hosted-review-header-chrome' - -const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) - -vi.mock('@/lib/http-link-routing', () => ({ - openHttpLink: openHttpLinkMock, - registerHttpLinkStoreAccessor: vi.fn() -})) - -function makeReview(overrides: Partial = {}): HostedReviewInfo { - return { - provider: 'github', - number: 2192, - title: 'Open PR in checks', - state: 'open', - url: 'https://github.com/stablyai/orca/pull/2192', - status: 'pending', - updatedAt: '2026-05-17T00:00:00Z', - mergeable: 'UNKNOWN', - ...overrides - } -} - -type MinimalClickEvent = Pick< - React.MouseEvent, - 'nativeEvent' | 'stopPropagation' -> -type ClickModifiers = Partial> - -function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent { - return { - nativeEvent: { - metaKey: modifiers.metaKey ?? false, - ctrlKey: modifiers.ctrlKey ?? false, - shiftKey: modifiers.shiftKey ?? false - } as MouseEvent, - stopPropagation: vi.fn() - } -} - -beforeEach(() => { - openHttpLinkMock.mockReset() -}) - -describe('HostedReviewHeaderLink', () => { - it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => { - const onOpenHostedReviewInChecks = vi.fn() - const element = HostedReviewHeaderLink({ - review: makeReview(), - onOpenHostedReviewInChecks - }) - const markup = renderToStaticMarkup(element) - - expect(markup).toContain(' void)(event) - expect(event.stopPropagation).toHaveBeenCalledTimes(1) - expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) - expect(openHttpLinkMock).not.toHaveBeenCalled() - }) - - it.each<[string, ClickModifiers]>([ - ['Cmd-click', { metaKey: true }], - ['Ctrl-click', { ctrlKey: true }], - ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], - ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] - ])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => { - const onOpenHostedReviewInChecks = vi.fn() - const element = HostedReviewHeaderLink({ - review: makeReview(), - onOpenHostedReviewInChecks - }) - - const event = clickEvent(modifiers) - ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) - - expect(event.stopPropagation).toHaveBeenCalledTimes(1) - expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) - expect(openHttpLinkMock).not.toHaveBeenCalled() - }) - - it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => { - const onOpenHostedReviewInChecks = vi.fn() - const element = HostedReviewHeaderLink({ - review: makeReview({ - provider: 'gitlab', - number: 31, - url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' - }), - onOpenHostedReviewInChecks - }) - const markup = renderToStaticMarkup(element) - - expect(markup).toContain(' void)(event) - expect(event.stopPropagation).toHaveBeenCalledTimes(1) - expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) - expect(openHttpLinkMock).not.toHaveBeenCalled() - }) - - it.each<[string, ClickModifiers]>([ - ['Cmd-click', { metaKey: true }], - ['Ctrl-click', { ctrlKey: true }], - ['Shift+Cmd-click', { metaKey: true, shiftKey: true }], - ['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }] - ])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => { - const onOpenHostedReviewInChecks = vi.fn() - const element = HostedReviewHeaderLink({ - review: makeReview({ - provider: 'gitlab', - number: 31, - url: 'https://gitlab.com/acme/widgets/-/merge_requests/31' - }), - onOpenHostedReviewInChecks - }) - - const event = clickEvent(modifiers) - ;(element.props.onClick as (event: MinimalClickEvent) => void)(event) - - expect(event.stopPropagation).toHaveBeenCalledTimes(1) - expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1) - expect(openHttpLinkMock).not.toHaveBeenCalled() - }) - - it('keeps other provider reviews as external hosted-review links', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain(' activeWorktreeId @@ -2806,11 +2802,6 @@ function SourceControlInner(): React.JSX.Element { ] ) - const openHostedReviewInChecks = useCallback(() => { - setRightSidebarOpen(true) - setRightSidebarTab('checks') - }, [setRightSidebarOpen, setRightSidebarTab]) - const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { // Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft. await refreshActiveGitStatusAfterMutation() @@ -4239,10 +4230,6 @@ function SourceControlInner(): React.JSX.Element { (!createPrHeaderAction.disabled || isCreatingPr || prGenerating) ? createPrHeaderAction : null - const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({ - createPrHeaderAction - }) - const dropdownItems: DropdownEntry[] = useMemo( () => resolveDropdownItems({ @@ -4710,19 +4697,6 @@ function SourceControlInner(): React.JSX.Element { runCreatePrIntent ]) - const handleCreatePrHeaderClick = useCallback((): void => { - if (!createPrHeaderAction || createPrHeaderAction.disabled) { - return - } - if (createPrHeaderAction.kind === 'create_pr') { - void handleCreatePullRequest() - return - } - if (createPrHeaderAction.kind === 'create_pr_intent') { - void runCreatePrIntent() - } - }, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent]) - const branchCompareInFlightRef = useRef(false) const branchCompareRerunRef = useRef(false) const branchCompareRunPromiseRef = useRef | null>(null) @@ -5452,16 +5426,11 @@ function SourceControlInner(): React.JSX.Element { <>
- {detachedHeadDisplay && ( -
- -
- )} - {/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */} {activeWorktreeId && worktreePath && diffCommentCount > 0 && (
diff --git a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx b/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx deleted file mode 100644 index 66733779c77..00000000000 --- a/src/renderer/src/components/right-sidebar/hosted-review-header-chrome.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { GitMerge } from 'lucide-react' -import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { cn } from '@/lib/utils' -import { PullRequestIcon } from './checks-panel-content' - -function hostedReviewStateClass(review: HostedReviewInfo): string { - if (review.state === 'merged') { - return 'text-purple-500/80' - } - if (review.state === 'open') { - return 'text-emerald-500/80' - } - if (review.state === 'closed') { - return 'text-muted-foreground/60' - } - return 'text-muted-foreground/50' -} - -export function HostedReviewIcon({ - review, - className -}: { - review: HostedReviewInfo - className?: string -}): React.JSX.Element { - const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon - return -} - -function hostedReviewLabel(review: HostedReviewInfo): string { - return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}` -} - -export function HostedReviewHeaderLink({ - review, - onOpenHostedReviewInChecks -}: { - review: HostedReviewInfo - onOpenHostedReviewInChecks: () => void -}): React.JSX.Element { - const label = hostedReviewLabel(review) - const className = - 'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground' - - if (review.provider === 'github' || review.provider === 'gitlab') { - return ( - - ) - } - - return ( - e.stopPropagation()} - > - {label} - - ) -} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts deleted file mode 100644 index b7214084d24..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' -import type { PrimaryAction } from './source-control-primary-action-types' - -const disabledCreatePrAction: PrimaryAction = { - kind: 'create_pr', - label: 'Create PR', - title: 'Publish commits before creating a pull request.', - disabled: true -} - -const enabledCreatePrAction: PrimaryAction = { - kind: 'create_pr', - label: 'Create PR', - title: 'Create a pull request for this branch', - disabled: false -} - -describe('resolveVisibleCreatePrHeaderAction', () => { - it('returns null when no header action is available', () => { - expect( - resolveVisibleCreatePrHeaderAction({ - createPrHeaderAction: null - }) - ).toBeNull() - }) - - it('keeps a disabled Create PR header visible as a stable toolbar anchor', () => { - expect( - resolveVisibleCreatePrHeaderAction({ - createPrHeaderAction: disabledCreatePrAction - }) - ).toEqual(disabledCreatePrAction) - }) - - it('keeps an enabled Create PR header visible even when the body composer is open', () => { - expect( - resolveVisibleCreatePrHeaderAction({ - createPrHeaderAction: enabledCreatePrAction - }) - ).toEqual(enabledCreatePrAction) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts index b40976642e1..82752fdac66 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -1,4 +1,3 @@ -import type { PrimaryAction } from './source-control-primary-action-types' import { resolveCreateReviewIntentEligibility, type CreateReviewIntentEligibility, @@ -11,13 +10,3 @@ export type CreatePrIntentKind = CreateReviewIntentKind export type CreatePrIntentEligibility = CreateReviewIntentEligibility export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility - -export function resolveVisibleCreatePrHeaderAction({ - createPrHeaderAction -}: { - createPrHeaderAction: PrimaryAction | null -}): PrimaryAction | null { - // Why: keep a stable header anchor; disable Create PR when the branch is not - // ready instead of hiding it and shifting the toolbar layout. - return createPrHeaderAction -} diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx new file mode 100644 index 00000000000..77ca1a6ac52 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.tsx @@ -0,0 +1,90 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { SourceControlHeaderToolbar } from './source-control-header-toolbar' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./source-control-header-overflow-menu', () => ({ + SourceControlHeaderOverflowMenu: () => +})) + +vi.mock('./source-control-branch-context-row', () => ({ + shouldShowSourceControlBranchContextRow: () => false, + SourceControlBranchContextRow: () => null +})) + +function renderToolbar( + gitIdentityDisplay: WorktreeGitIdentityDisplay | null = { + kind: 'branch', + branchName: 'brennanb2025/source-control-branch-name' + } +): string { + return renderToStaticMarkup( + + ) +} + +describe('SourceControlHeaderToolbar', () => { + it('renders the truncating branch identity before source control actions', () => { + const markup = renderToolbar() + const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name') + const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') + + expect(branchIndex).toBeGreaterThan(-1) + expect(filterIndex).toBeGreaterThan(branchIndex) + expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"') + expect(markup).toContain('tabindex="0"') + expect(markup).toContain('min-w-0 truncate') + expect(markup).not.toContain('Create PR') + }) + + it('renders detached HEAD in the same identity slot', () => { + const markup = renderToolbar({ + kind: 'detached', + shortHead: '8cec248', + sidebarLabel: 'Detached HEAD @ 8cec248', + sourceControlLabel: 'Detached HEAD · 8cec248', + tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.' + }) + const identityIndex = markup.indexOf('Detached HEAD · 8cec248') + const filterIndex = markup.indexOf('data-testid="source-control-filter-toggle"') + + expect(markup).not.toContain('aria-label="Current branch:') + expect(identityIndex).toBeGreaterThan(-1) + expect(filterIndex).toBeGreaterThan(identityIndex) + expect(markup).toContain( + 'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."' + ) + expect(markup).toContain('tabindex="0"') + expect(markup).toContain('lucide-git-commit-horizontal') + }) + + it('keeps the identity slot empty until git identity is known', () => { + const markup = renderToolbar(null) + + expect(markup).not.toContain('aria-label="Current branch:') + expect(markup).not.toContain('Detached HEAD') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index 3cac51e1e9f..9c8a32397fe 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -1,17 +1,16 @@ import React, { useCallback, useEffect, useRef } from 'react' -import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' +import { GitBranch, Search, X } from 'lucide-react' import type { GitBranchCompareSummary, GitUpstreamStatus, SourceControlViewMode } from '../../../../shared/types' -import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome' +import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { shouldShowSourceControlBranchContextRow, SourceControlBranchContextRow @@ -19,16 +18,11 @@ import { import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu' type SourceControlHeaderToolbarProps = { + gitIdentityDisplay: WorktreeGitIdentityDisplay | null filterQuery: string filterExpanded: boolean onFilterQueryChange: (value: string) => void onFilterExpandedChange: (expanded: boolean) => void - visibleCreatePrHeaderAction: PrimaryAction | null - hostedReview: HostedReviewInfo | null - isCreatePrIntentInFlight: boolean - isCreatingPr: boolean - onCreatePrHeaderClick: () => void - onOpenHostedReviewInChecks: () => void sourceControlViewMode: SourceControlViewMode viewModeToggleDisabled: boolean onToggleViewMode: () => void @@ -43,65 +37,45 @@ type SourceControlHeaderToolbarProps = { manualReviewUrl?: string | null } -function HostedReviewToolbarLink({ - review, - onOpenHostedReviewInChecks, - compact +function SourceControlGitIdentityLabel({ + display }: { - review: HostedReviewInfo - onOpenHostedReviewInChecks: () => void - compact?: boolean + display: WorktreeGitIdentityDisplay }): React.JSX.Element { - return ( -
- - -
+ if (display.kind === 'detached') { + return ( + + + + ) + } + + const branchName = display.branchName + const label = translate( + 'auto.components.right.sidebar.SourceControl.a4e93c21d7', + 'Current branch: {{value0}}', + { value0: branchName } ) -} -function CreatePrHeaderButton({ - action, - isCreatePrIntentInFlight, - isCreatingPr, - onClick -}: { - action: PrimaryAction - isCreatePrIntentInFlight: boolean - isCreatingPr: boolean - onClick: () => void -}): React.JSX.Element { return ( - - + + - - {action.title} + + {branchName} ) @@ -124,16 +98,11 @@ function renderOverflowMenu( } export function SourceControlHeaderToolbar({ + gitIdentityDisplay, filterQuery, filterExpanded, onFilterQueryChange, onFilterExpandedChange, - visibleCreatePrHeaderAction, - hostedReview, - isCreatePrIntentInFlight, - isCreatingPr, - onCreatePrHeaderClick, - onOpenHostedReviewInChecks, sourceControlViewMode, viewModeToggleDisabled, onToggleViewMode, @@ -196,25 +165,11 @@ export function SourceControlHeaderToolbar({ > {showCollapsedToolbar ? ( <> - {hostedReview ? ( - - ) : visibleCreatePrHeaderAction ? ( - + {gitIdentityDisplay ? ( + ) : (
+ {targetRemoved ? ( + + ) : ( + + )} ) From 8f40419800e62065ed4448182a689e0c4b85f26e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 22 Jul 2026 17:04:17 -0400 Subject: [PATCH 05/29] fix(agent-status): cap the live agent-status map to stop Windows renderer OOM (#9872) (#9936) The live `agentStatusByPaneKey` map had no size cap. `setAgentStatus` rewrites it with a full spread copy on every status ping and keeps rows until a pane/tab teardown event removes them; a missed teardown (agent killed without a Stop hook, pane/tab closed while its status lingered) orphans a row forever. On long multi-agent sessions orphaned heavy rows accumulate without bound, and because each ping spread-copies the whole map, once it is ~1.9 GB one ping transiently doubles it past the 3586 MB old-space limit -> renderer crash (exit -36861). Cap the live map at MAX_LIVE_AGENT_STATUSES=500, shedding only rows whose pane is provably gone (a mounted tab's rooted layout no longer lists the leaf) or long idle, and never an open pane's row (any state, incl. needs-input waiting/blocked). Rootless/empty-snapshot, not-yet-hydrated, and no-renderer-tab (orchestration worker) rows are unprovable: kept while a fresh agent could own them, shed only past the stale window or by a hard-cap fallback that guarantees the bound. Eviction bumps the status/sort epochs so the retention sync snapshots disappeared done rows. Cost is one Object.keys length check on the reducer's already-O(n) spread under the cap; the layout walk + sweeps run only on the rare over-cap ping. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../slices/agent-status-live-map-leak.test.ts | 326 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 100 +++++- 2 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-live-map-leak.test.ts diff --git a/src/renderer/src/store/slices/agent-status-live-map-leak.test.ts b/src/renderer/src/store/slices/agent-status-live-map-leak.test.ts new file mode 100644 index 00000000000..5074b51a681 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-map-leak.test.ts @@ -0,0 +1,326 @@ +/** Regression for #9872: missed pane teardown must not let heavy live status rows grow unbounded. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { MAX_LIVE_AGENT_STATUSES } from './agent-status' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' +import type { AppState } from '../types' + +// Use the production payload limits so the regression represents the leaked byte weight. +const BIG_ASSISTANT_MESSAGE = 'a'.repeat(8 * 1024) +const BIG_INTERACTIVE_PROMPT = 'q'.repeat(16 * 1024) + +function seedWorktree(store: ReturnType): void { + store.setState({ + repos: [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#999999', + addedAt: 1, + kind: 'git' + } + ], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' })] + }, + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-live', worktreeId: 'wt-1' })] + }, + terminalLayoutsByTabId: { + 'tab-live': { + root: { type: 'leaf', leafId: 'leaf-live' }, + activeLeafId: 'leaf-live', + expandedLeafId: null + } + } + } as Partial) +} + +function donePayload(index: number): ParsedAgentStatusPayload { + return { + state: 'done', + prompt: `prompt ${index}`, + agentType: 'claude', + lastAssistantMessage: BIG_ASSISTANT_MESSAGE, + interactivePrompt: BIG_INTERACTIVE_PROMPT + } as ParsedAgentStatusPayload +} + +function workingPayload(index: number): ParsedAgentStatusPayload { + return { + state: 'working', + prompt: `busy ${index}`, + agentType: 'claude' + } as ParsedAgentStatusPayload +} + +function sleepingRecord(paneKey: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId: paneKey.slice(0, paneKey.indexOf(':')), + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: `session-${paneKey}` }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } +} + +function setAgentAt( + store: ReturnType, + paneKey: string, + payload: ParsedAgentStatusPayload, + updatedAt?: number +): void { + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + store + .getState() + .setAgentStatus( + paneKey, + payload, + undefined, + updatedAt === undefined ? undefined : { updatedAt }, + { + tabId, + worktreeId: 'wt-1' + } + ) +} + +/** Add rows for leaves absent from a mounted tab's rooted layout. */ +function churnDeadLeaves( + store: ReturnType, + count: number, + makePayload: (i: number) => ParsedAgentStatusPayload = donePayload +): void { + for (let i = 0; i < count; i++) { + setAgentAt(store, `tab-live:dead-${i}`, makePayload(i)) + } +} + +/** Add fresh rows whose pane liveness cannot be proven from renderer layouts. */ +function churnFreshUnprovable(store: ReturnType, count: number): void { + for (let i = 0; i < count; i++) { + setAgentAt(store, `gone-${i}:leaf-${i}`, workingPayload(i)) + } +} + +describe('agentStatusByPaneKey stays bounded (leak regression #9872)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('caps the live map at the limit, evicting the oldest dead-leaf orphans', () => { + const store = createTestStore() + seedWorktree(store) + + const total = MAX_LIVE_AGENT_STATUSES + 1500 + churnDeadLeaves(store, total) + + const live = store.getState().agentStatusByPaneKey + expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES) + expect(live[`tab-live:dead-${total - 1}`]).toBeDefined() + expect(live['tab-live:dead-0']).toBeUndefined() + }) + + it("never evicts a live pane's working agent", () => { + const store = createTestStore() + seedWorktree(store) + setAgentAt(store, 'tab-live:leaf-live', workingPayload(0)) + + churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES + 1500) + + expect(store.getState().agentStatusByPaneKey['tab-live:leaf-live']?.state).toBe('working') + }) + + it("never evicts a live pane's waiting or blocked row, even under the hard-cap fallback", () => { + const store = createTestStore() + seedWorktree(store) + store.setState({ + terminalLayoutsByTabId: { + 'tab-live': { + root: { type: 'leaf', leafId: 'leaf-live' }, + activeLeafId: 'leaf-live', + expandedLeafId: null + }, + 'tab-w': { + root: { type: 'leaf', leafId: 'leaf-w' }, + activeLeafId: 'leaf-w', + expandedLeafId: null + }, + 'tab-b': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null + } + } + } as Partial) + setAgentAt(store, 'tab-w:leaf-w', { + state: 'waiting', + prompt: 'needs input', + agentType: 'claude' + } as ParsedAgentStatusPayload) + setAgentAt(store, 'tab-b:leaf-b', { + state: 'blocked', + prompt: 'perm prompt', + agentType: 'claude' + } as ParsedAgentStatusPayload) + + churnFreshUnprovable(store, MAX_LIVE_AGENT_STATUSES + 1500) + + const live = store.getState().agentStatusByPaneKey + expect(live['tab-w:leaf-w']?.state).toBe('waiting') + expect(live['tab-b:leaf-b']?.state).toBe('blocked') + }) + + it('bounds the map when fresh unprovable rows dominate, without evicting a live pane', () => { + const store = createTestStore() + seedWorktree(store) + setAgentAt(store, 'tab-live:leaf-live', donePayload(-1)) + + churnFreshUnprovable(store, MAX_LIVE_AGENT_STATUSES + 200) + + const live = store.getState().agentStatusByPaneKey + expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES) + expect(live['tab-live:leaf-live']?.state).toBe('done') + }) + + it('keeps fresh rows of rootless / empty-snapshot / no-renderer-tab panes (live agents, #2962)', () => { + const store = createTestStore() + seedWorktree(store) + store.setState({ + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-live', worktreeId: 'wt-1' }), + makeTab({ id: 'bg-empty', worktreeId: 'wt-1' }) + ] + }, + terminalLayoutsByTabId: { + 'tab-live': { + root: { type: 'leaf', leafId: 'leaf-live' }, + activeLeafId: 'leaf-live', + expandedLeafId: null + }, + 'bg-empty': { root: null, activeLeafId: null, expandedLeafId: null }, + 'bg-bound': { + root: null, + activeLeafId: 'leaf-bound', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-bound': 'pty-1' } + } + } + } as Partial) + setAgentAt(store, 'bg-empty:leaf-bg', { + state: 'waiting', + prompt: 'needs input', + agentType: 'claude' + } as ParsedAgentStatusPayload) + setAgentAt(store, 'bg-bound:leaf-bound', donePayload(-2)) + setAgentAt(store, 'worker-tab:leaf-worker', workingPayload(-3)) + + churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES + 200) + + const live = store.getState().agentStatusByPaneKey + expect(live['bg-empty:leaf-bg']?.state).toBe('waiting') + expect(live['bg-bound:leaf-bound']?.state).toBe('done') + expect(live['worker-tab:leaf-worker']?.state).toBe('working') + }) + + it('evicts an idle unprovable orphan once past the stale window, keeping fresh ones', () => { + vi.useFakeTimers() + const late = new Date('2026-07-22T00:00:00.000Z').getTime() + vi.setSystemTime(late) + const store = createTestStore() + seedWorktree(store) + + setAgentAt(store, 'gone-stale:leaf', workingPayload(0), late - AGENT_STATUS_STALE_AFTER_MS - 1) + for (let i = 0; i < MAX_LIVE_AGENT_STATUSES; i++) { + setAgentAt(store, `gone-fresh-${i}:leaf`, workingPayload(i), late) + } + + const live = store.getState().agentStatusByPaneKey + expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES) + expect(live['gone-stale:leaf']).toBeUndefined() + expect(live['gone-fresh-0:leaf']?.state).toBe('working') + }) + + it('under cap: no eviction', () => { + const store = createTestStore() + seedWorktree(store) + + churnDeadLeaves(store, 10) + + expect(Object.keys(store.getState().agentStatusByPaneKey).length).toBe(10) + }) + + it('purges recovery and launch records for evicted pane keys', () => { + const store = createTestStore() + seedWorktree(store) + + churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES) + const evictedPaneKey = 'tab-live:dead-0' + const keptPaneKey = 'tab-live:dead-1' + store.setState({ + sleepingAgentSessionsByPaneKey: { + [evictedPaneKey]: sleepingRecord(evictedPaneKey), + [keptPaneKey]: sleepingRecord(keptPaneKey) + }, + agentLaunchConfigByPaneKey: { + [evictedPaneKey]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + }, + [keptPaneKey]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: {} + } + } + } as Partial) + + setAgentAt(store, 'tab-live:dead-extra', donePayload(MAX_LIVE_AGENT_STATUSES)) + + const state = store.getState() + expect(state.agentStatusByPaneKey[evictedPaneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[evictedPaneKey]).toBeUndefined() + expect(state.agentLaunchConfigByPaneKey[evictedPaneKey]).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[keptPaneKey]).toBeDefined() + expect(state.agentLaunchConfigByPaneKey[keptPaneKey]).toBeDefined() + }) + + it('bumps epochs when eviction is the only sort-relevant change', () => { + const store = createTestStore() + seedWorktree(store) + + churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES, workingPayload) + const existingPaneKey = 'tab-live:dead-0' + const existing = store.getState().agentStatusByPaneKey[existingPaneKey] + store.setState({ + agentStatusByPaneKey: { + ...store.getState().agentStatusByPaneKey, + 'tab-live:dead-extra': { + ...existing, + paneKey: 'tab-live:dead-extra' + } + } + } as Partial) + const statusEpochBefore = store.getState().agentStatusEpoch + const sortEpochBefore = store.getState().sortEpoch + + setAgentAt(store, existingPaneKey, workingPayload(0), existing.updatedAt) + + const state = store.getState() + expect(Object.keys(state.agentStatusByPaneKey).length).toBe(MAX_LIVE_AGENT_STATUSES) + expect(state.agentStatusEpoch).toBe(statusEpochBefore + 1) + expect(state.sortEpoch).toBe(sortEpochBefore + 1) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index cc07e93f076..80f7a8da25b 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -26,7 +26,7 @@ import { shouldSuppressInheritedTerminalStatus } from '../../../../shared/agent-status-identity' import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types' import { getRepoExecutionHostId, getWorktreeExecutionHostId @@ -268,6 +268,82 @@ function capRetainedAgents( return capped } +// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). +export const MAX_LIVE_AGENT_STATUSES = 500 + +type PaneLiveness = 'live' | 'dead' | 'unprovable' + +// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). +function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { + const rootedLeafKeys = new Set() + const rootedTabIds = new Set() + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (!layout?.root) { + continue + } + rootedTabIds.add(tabId) + const stack: TerminalPaneLayoutNode[] = [layout.root] + while (stack.length > 0) { + const node = stack.pop()! + if (node.type === 'leaf') { + rootedLeafKeys.add(`${tabId}:${node.leafId}`) + } else { + stack.push(node.first, node.second) + } + } + } + return (paneKey) => { + if (rootedLeafKeys.has(paneKey)) { + return 'live' + } + const tabId = getTabIdFromPaneKey(paneKey) + return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' + } +} + +// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. +function capLiveAgentStatusesInPlace( + freshLive: Record, + protectedPaneKey: string, + buildClassifier: () => (paneKey: string) => PaneLiveness, + now: number, + maxEntries = MAX_LIVE_AGENT_STATUSES +): string[] { + const keys = Object.keys(freshLive) + let overflow = keys.length - maxEntries + if (overflow <= 0) { + return [] + } + const classify = buildClassifier() + const evictedPaneKeys: string[] = [] + const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { + for (const key of keys) { + if (overflow <= 0) { + break + } + if (key === protectedPaneKey || !(key in freshLive)) { + continue + } + const liveness = classify(key) + if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { + continue + } + delete freshLive[key] + overflow -= 1 + evictedPaneKeys.push(key) + } + } + // Prefer rows that are provably dead or too stale to represent a live agent. + sweep( + (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) + // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. + if (overflow > 0) { + sweep(() => true) + } + return evictedPaneKeys +} + function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { for (const prefix of tabPrefixes) { if (paneKey.startsWith(prefix)) { @@ -1916,19 +1992,35 @@ export const createAgentStatusSlice: StateCreator classifyPaneKeyLiveness(s), + updatedAt + ) + const evictedOrphans = evictedPaneKeys.length > 0 + if (evictedOrphans) { + const evictedPaneKeySet = new Set(evictedPaneKeys) + nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evictedPaneKeySet) + nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evictedPaneKeySet) + } return { - agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry }, + agentStatusByPaneKey: nextLive, retainedAgentsByPaneKey: nextRetainedAgents, sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, agentLaunchConfigByPaneKey: nextLaunchConfigs, migrationUnsupportedByPtyId: migrationUnsupported.next, retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, agentStatusEpoch: - retentionRelevantChange || migrationUnsupported.changed + retentionRelevantChange || migrationUnsupported.changed || evictedOrphans ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, sortEpoch: - sortRelevantChange || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + sortRelevantChange || migrationUnsupported.changed || evictedOrphans + ? s.sortEpoch + 1 + : s.sortEpoch } }) if (suppressedInheritedTerminalStatus) { From d6c9fcd53764c3913df9ccfe6bd246e1faf50e5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:05:37 -0700 Subject: [PATCH 06/29] feat(mobile): surface pairing-auth failures on desktop and mobile (#9782) * feat(mobile-pairing): surface unpaired-device auth failures instead of silent 4001 loops Desktop: when a phone repeatedly fails direct-transport E2EE auth with a token missing from the device registry (pre-v1.4.106 pairing-path bug left desktops that regenerated their registry rejecting paired phones forever), throttle to one notification per session and show an actionable toast pointing at Settings -> Mobile to re-pair. Mobile: map a bare 4001 close onto the existing auth retry budget (the encrypted e2ee_error is undecryptable when the desktop keypair changed, so the close code is the only surviving signal) instead of looping the generic reconnect forever, and make the auth-failed verdict say 'Pairing invalid - re-pair with your desktop' instead of a bare 'Auth failed'. * fix(mobile-pairing): handle stale keys and startup notification races * fix(mobile-pairing): isolate auth notification failures * fix(mobile-pairing): keep recovery alert actionable --- .../session/mobile-session-route-helpers.ts | 2 +- .../src/transport/connection-health.test.ts | 13 ++ mobile/src/transport/connection-health.ts | 4 +- .../rpc-client-unauthorized-close.test.ts | 169 ++++++++++++++++++ mobile/src/transport/rpc-client.ts | 19 +- src/main/index.ts | 25 ++- src/main/ipc/mobile.test.ts | 19 ++ src/main/ipc/mobile.ts | 8 + src/main/runtime/rpc/e2ee-channel.test.ts | 20 +++ src/main/runtime/rpc/e2ee-channel.ts | 14 +- .../runtime/rpc/mobile-socket-wiring.test.ts | 91 ++++++++++ src/main/runtime/rpc/mobile-socket-wiring.ts | 13 ++ .../rpc/unpaired-device-auth-throttle.test.ts | 63 +++++++ .../rpc/unpaired-device-auth-throttle.ts | 47 +++++ src/main/runtime/runtime-rpc.ts | 18 ++ src/preload/api-types.ts | 4 + src/preload/index.ts | 10 ++ .../unpaired-device-auth-notification.test.ts | 85 +++++++++ .../unpaired-device-auth-notification.ts | 37 ++++ src/renderer/src/hooks/useIpcEvents.ts | 31 ++++ src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/renderer/src/web/web-preload-api.ts | 4 +- 26 files changed, 702 insertions(+), 19 deletions(-) create mode 100644 mobile/src/transport/rpc-client-unauthorized-close.test.ts create mode 100644 src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts create mode 100644 src/main/runtime/rpc/unpaired-device-auth-throttle.ts create mode 100644 src/renderer/src/hooks/unpaired-device-auth-notification.test.ts create mode 100644 src/renderer/src/hooks/unpaired-device-auth-notification.ts diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index b5a5049dd06..52b277bd6b8 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -7,7 +7,7 @@ export const MOBILE_SESSION_STATUS_LABELS: Record = { connected: 'Connected', disconnected: 'Disconnected', reconnecting: 'Reconnecting', - 'auth-failed': 'Auth failed' + 'auth-failed': 'Pairing invalid' } export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64 diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts index 22e752a6335..4a0973dae74 100644 --- a/mobile/src/transport/connection-health.test.ts +++ b/mobile/src/transport/connection-health.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from 'vitest' import { classifyConnection, verdictDisplayLabel } from './connection-health' +describe('classifyConnection auth-failed verdict', () => { + it('tells the user to re-pair instead of showing a generic auth error', () => { + const verdict = classifyConnection({ + state: 'auth-failed', + reconnectAttempts: 0, + lastConnectedAt: null, + nowMs: 1_000_000 + }) + expect(verdict.kind).toBe('auth-failed') + expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop') + }) +}) + describe('classifyConnection Tailscale hint', () => { const base = { state: 'reconnecting' as const, diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index d3b8251fc1b..c244b6c9afd 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -55,8 +55,10 @@ export function classifyConnection(args: { const now = args.nowMs ?? Date.now() const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined + // Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it + // lost its device registry) — retrying can't fix it, only re-pairing can, so say so. if (state === 'auth-failed') { - return { kind: 'auth-failed', label: 'Auth failed' } + return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' } } // Connected / connecting / handshaking are normal. diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts new file mode 100644 index 00000000000..5fb4ab18e40 --- /dev/null +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -0,0 +1,169 @@ +// Why: separate from rpc-client.test.ts — that file sits at its max-lines cap, +// and the silent-4001 mapping (desktop lost its device registry / regenerated +// its keypair, so the encrypted e2ee_error never decrypts) is its own scenario. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSING = MockWebSocket.CLOSING + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } + + serverClose(code: number, reason = ''): void { + this.readyState = MockWebSocket.CLOSED + this.onclose?.({ code, reason, wasClean: true }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function lastSocket(): MockWebSocket { + return mockSockets[mockSockets.length - 1]! +} + +describe('unauthorized close-code mapping (silent 4001)', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('counts a bare 4001 close against the auth retry budget and latches auth-failed', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + + // A desktop with a regenerated keypair can't send a decryptable e2ee_error — + // the phone only ever sees the 4001 close. Three of those must latch. + for (let i = 0; i < 3; i++) { + if (i > 0) { + await vi.advanceTimersByTimeAsync(500) + } + const socket = lastSocket() + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.serverClose(4001, 'Unauthorized') + } + + expect(client.getState()).toBe('auth-failed') + expect(mockSockets).toHaveLength(3) + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(mockSockets).toHaveLength(3) + + client.close() + }) + + it('recovers when a 4001 close was transient and the next handshake succeeds', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const first = mockSockets[0]! + first.open() + first.receive(JSON.stringify({ type: 'e2ee_ready' })) + first.serverClose(4001, 'Unauthorized') + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + const next = lastSocket() + next.open() + next.receive(JSON.stringify({ type: 'e2ee_ready' })) + next.receive('encrypted:{"type":"e2ee_authenticated"}') + expect(client.getState()).toBe('connected') + + client.close() + }) + + it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + + for (let i = 0; i < 3; i++) { + if (i > 0) { + await vi.advanceTimersByTimeAsync(500) + } + const socket = lastSocket() + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + if (i < 2) { + socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}') + } else { + socket.serverClose(4001, 'Unauthorized') + } + } + + expect(client.getState()).toBe('auth-failed') + + client.close() + }) + + it('keeps the generic reconnect loop for non-4001 closes', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + + for (let i = 0; i < 5; i++) { + if (i > 0) { + await vi.advanceTimersByTimeAsync(60_000) + } + const socket = lastSocket() + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.serverClose(1006) + } + + expect(client.getState()).toBe('reconnecting') + + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9afec2bc61b..9b0b6246f1c 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -98,6 +98,8 @@ const GIVE_UP_AFTER_ATTEMPTS = 12 const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed. const AUTH_RETRY_BUDGET = 3 +// Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal. +const UNAUTHORIZED_CLOSE_CODE = 4001 const REQUEST_TIMEOUT_MS = 30_000 const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 @@ -603,7 +605,7 @@ export function connect( }) lastWsClosedAt = closeAt currentWsOpenedAt = null - handleSocketClosed(openingWs) + handleSocketClosed(openingWs, { closeCode: e?.code }) } ws.onerror = (event) => { @@ -623,7 +625,10 @@ export function connect( } } - function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) { + function handleSocketClosed( + closedWs: WebSocket, + opts: { timedOut?: boolean; closeCode?: number } = {} + ) { if (ws !== closedWs) { console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', { state, @@ -648,6 +653,16 @@ export function connect( rejectAllPending('Connection closed', { deliveryUnknown: true }) return } + // Why: a bare 4001 close means the desktop rejected our pairing but the encrypted + // e2ee_error never arrived (or was undecryptable) — count it against the auth + // retry budget instead of looping the generic reconnect forever. + if (opts.closeCode === UNAUTHORIZED_CLOSE_CODE) { + console.log('[net] handleSocketClosed — unauthorized close code', { + attempt: reconnectAttempt + }) + handleAuthRejection('Unauthorized — pairing may be revoked') + return + } console.log('[net] handleSocketClosed → reconnect', { timedOut: !!opts.timedOut, pendingCount: pending.size, diff --git a/src/main/index.ts b/src/main/index.ts index a24373bd647..494760c48c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -253,6 +253,7 @@ let runtimeRpc: OrcaRuntimeRpcServer | null = null const serveReadinessPublisher = new ServeReadinessPublisher() let desktopRelayService: DesktopRelayService | null = null let desktopRelayStatus: RelayBrokerStatus = 'offline' +let pendingUnpairedDeviceAuthFailure = false // Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). let headlessBrowserDisplayAvailable = false @@ -2281,7 +2282,29 @@ app.whenReady().then(async () => { : {}), webClientRoot: getBundledWebClientRoot() }) - registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus }) + registerMobileHandlers(runtimeRpc, { + getRelayStatus: () => desktopRelayStatus, + consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { + if ( + !mainWindow || + mainWindow.isDestroyed() || + mainWindow.webContents.id !== webContentsId || + !pendingUnpairedDeviceAuthFailure + ) { + return false + } + pendingUnpairedDeviceAuthFailure = false + return true + } + }) + // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. + runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. + pendingUnpairedDeviceAuthFailure = true + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') + } + }) startTerminalRuntimeStartupServices() app.on('activate', requestDesktopActivation) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index ff53b23374e..1a3ca71031c 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -273,6 +273,25 @@ describe('registerMobileHandlers', () => { expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' }) }) + it('consumes a pending auth-failure notification only from a window renderer', () => { + const consumePendingUnpairedDeviceAuthFailure = vi.fn(() => true) + registerMobileHandlers({} as never, { consumePendingUnpairedDeviceAuthFailure }) + + expect( + handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({ + sender: { id: 42, isDestroyed: () => false, getType: () => 'window' } + }) + ).toBe(true) + expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledWith(42) + + expect( + handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({ + sender: { id: 99, isDestroyed: () => false, getType: () => 'webview' } + }) + ).toBe(false) + expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledOnce() + }) + it('inspects and repairs the current packaged Windows websocket port', async () => { const runPowerShell = vi .fn() diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 46c83f1234a..f4e35fa1e6f 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -63,6 +63,7 @@ export type MobileHandlerDependencies = { firewallEnvironment?: WindowsMobileFirewallEnvironment openWindowsNetworkSettings?: () => Promise getRelayStatus?: () => RelayBrokerStatus + consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean } export function registerMobileHandlers( @@ -252,6 +253,13 @@ export function registerMobileHandlers( ipcMain.handle('mobile:getRelayStatus', () => ({ status: dependencies.getRelayStatus?.() ?? 'offline' })) + + ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => { + if (!isWindowRenderer(event)) { + return false + } + return dependencies.consumePendingUnpairedDeviceAuthFailure?.(event.sender.id) ?? false + }) } function isWindowRenderer(event: IpcMainInvokeEvent): boolean { diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 846b82903db..400a14de86d 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -113,6 +113,26 @@ describe('E2EEChannel', () => { expect(ctx.onReady).not.toHaveBeenCalled() }) + it('rejects an auth frame encrypted to a stale desktop key', () => { + const ctx = setup() + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const staleServer = generateKeyPair() + const staleSharedKey = deriveSharedKey(ctx.clientKeys.secretKey, staleServer.publicKey) + + ctx.channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), staleSharedKey) + ) + + expect(ctx.onError).toHaveBeenCalledOnce() + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(ctx.onReady).not.toHaveBeenCalled() + }) + it('rejects malformed JSON', () => { const ctx = setup() ctx.channel.handleRawMessage('not json') diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index cec40fd38ce..eeb6097b4e3 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -1,6 +1,4 @@ -// Why: the E2EE channel sits between the WebSocket transport and the RPC handler. -// It owns the handshake state machine and transparent encrypt/decrypt so the RPC -// handler only sees plaintext JSON, identical to the Unix socket path. +// Why: this channel keeps E2EE framing out of RPC handlers, which consume plaintext across transports. import type { WebSocket } from 'ws' import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto' import { @@ -53,9 +51,7 @@ export class E2EEChannel { private readonly requireV2: boolean private v2Session: DesktopMobileE2EEV2Session | null = null private v2OutboundQueue: WsOutboundBackpressureQueue | null = null - // Why: the RPC handler is set after the channel is ready, so the channel - // can forward decrypted messages. Kept as a callback rather than constructor - // param because the handler needs the encrypt function for replies. + // Why: the handler is set after readiness because its reply closure needs this channel's encryption state. private messageHandler: | (( plaintext: string, @@ -173,8 +169,10 @@ export class E2EEChannel { } private trackDecryptFailure(): void { - this.consecutiveFailures++ - if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + // Why: a wrong key cannot recover on this socket; close so the client uses its bounded auth retry budget. + if (this.state === 'awaiting_auth') { + this.onError(4001, 'Unauthorized') + } else if (++this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { this.onError(4003, 'Too many decryption failures') } } diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index e5cf9e9bff6..b53f20e572e 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -139,6 +139,97 @@ describe('MobileSocketWiring', () => { expect(wiring.connectionCount).toBe(0) }) + it('closes an unknown-token socket even when reporting the failure throws', () => { + const desktop = generateKeyPair() + const phone = generateKeyPair() + const ws = new FakeSocket() + const transport = new FakeTransport() + const notificationError = new Error('renderer exited') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const onUnpairedDeviceAuthFailure = vi.fn(() => { + throw notificationError + }) + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn(), + onUnpairedDeviceAuthFailure + }) + wiring.attachTransport(transport) + + transport.receive( + ws, + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(phone.publicKey).toString('base64') + }) + ) + const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey) + expect(() => + transport.receive( + ws, + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'stale-token' }), sharedKey) + ) + ).not.toThrow() + + expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledOnce() + expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledWith({ transport: 'direct' }) + expect(consoleError).toHaveBeenCalledWith( + '[mobile] Failed to report unpaired-device auth failure:', + notificationError + ) + expect(transport.setClientId).not.toHaveBeenCalled() + expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') + expect(wiring.channelCount).toBe(0) + consoleError.mockRestore() + }) + + it('reports auth encrypted to a stale desktop key on the direct path', () => { + const currentDesktop = generateKeyPair() + const staleDesktop = generateKeyPair() + const phone = generateKeyPair() + const ws = new FakeSocket() + const transport = new FakeTransport() + const onUnpairedDeviceAuthFailure = vi.fn() + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: currentDesktop.publicKey, + secretKey: currentDesktop.secretKey, + publicKeyB64: Buffer.from(currentDesktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn(), + onUnpairedDeviceAuthFailure + }) + wiring.attachTransport(transport) + + transport.receive( + ws, + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(phone.publicKey).toString('base64') + }) + ) + const staleSharedKey = deriveSharedKey(phone.secretKey, staleDesktop.publicKey) + transport.receive( + ws, + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), staleSharedKey) + ) + + expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledOnce() + expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledWith({ transport: 'direct' }) + expect(transport.setClientId).not.toHaveBeenCalled() + expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') + }) + it('rejects a relay socket whose immutable relayDeviceId differs from E2EE identity', () => { const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) const phone = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index bb7ece7be51..637b5185866 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -50,6 +50,8 @@ type MobileSocketWiringOptions = { onBinary: (socket: AuthenticatedMobileSocket, bytes: Uint8Array) => void onClose: (socket: AuthenticatedMobileSocket | null, hasOtherConnections: boolean) => void onReady?: (socket: AuthenticatedMobileSocket) => void + // Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action. + onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void } function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { @@ -67,6 +69,7 @@ export class MobileSocketWiring { private readonly onBinary: MobileSocketWiringOptions['onBinary'] private readonly onClose: MobileSocketWiringOptions['onClose'] private readonly onReady: MobileSocketWiringOptions['onReady'] + private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure'] private readonly channels = new Map() private readonly connectionIds = new Map() private readonly authenticatedSockets = new Map() @@ -79,6 +82,7 @@ export class MobileSocketWiring { this.onBinary = options.onBinary this.onClose = options.onClose this.onReady = options.onReady + this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure } attachTransport( @@ -151,9 +155,18 @@ export class MobileSocketWiring { this.onReady?.(socket) }, onError: (code, reason) => { + const reportUnpairedDevice = code === 4001 && reason === 'Unauthorized' this.channels.get(ws)?.destroy() this.channels.delete(ws) ws.close(code, reason) + if (reportUnpairedDevice) { + try { + this.onUnpairedDeviceAuthFailure?.(metadata) + } catch (error) { + // Why: renderer teardown can make UI delivery throw; auth cleanup must remain authoritative. + console.error('[mobile] Failed to report unpaired-device auth failure:', error) + } + } } }) channel.onMessage((plaintext, reply, sendBinary) => { diff --git a/src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts b/src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts new file mode 100644 index 00000000000..d27116060f0 --- /dev/null +++ b/src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { UnpairedDeviceAuthThrottle } from './unpaired-device-auth-throttle' + +function throttleAt(clock: { time: number }, onTrigger = vi.fn()) { + const throttle = new UnpairedDeviceAuthThrottle({ + onTrigger, + failureThreshold: 3, + windowMs: 60_000, + now: () => clock.time + }) + return { throttle, onTrigger } +} + +describe('UnpairedDeviceAuthThrottle', () => { + it('stays silent below the failure threshold', () => { + const clock = { time: 0 } + const { throttle, onTrigger } = throttleAt(clock) + throttle.recordFailure() + clock.time += 1000 + throttle.recordFailure() + expect(onTrigger).not.toHaveBeenCalled() + }) + + it('fires once when the threshold is reached inside the window', () => { + const clock = { time: 0 } + const { throttle, onTrigger } = throttleAt(clock) + for (let i = 0; i < 3; i++) { + throttle.recordFailure() + clock.time += 500 + } + expect(onTrigger).toHaveBeenCalledOnce() + }) + + it('never fires twice in one session even as failures continue', () => { + const clock = { time: 0 } + const { throttle, onTrigger } = throttleAt(clock) + for (let i = 0; i < 20; i++) { + throttle.recordFailure() + clock.time += 500 + } + expect(onTrigger).toHaveBeenCalledOnce() + }) + + it('ignores failures that fall outside the window', () => { + const clock = { time: 0 } + const { throttle, onTrigger } = throttleAt(clock) + throttle.recordFailure() + clock.time += 61_000 + throttle.recordFailure() + clock.time += 61_000 + throttle.recordFailure() + expect(onTrigger).not.toHaveBeenCalled() + + // A real retry burst after the stray singles still triggers. + clock.time += 61_000 + throttle.recordFailure() + clock.time += 100 + throttle.recordFailure() + clock.time += 100 + throttle.recordFailure() + expect(onTrigger).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/rpc/unpaired-device-auth-throttle.ts b/src/main/runtime/rpc/unpaired-device-auth-throttle.ts new file mode 100644 index 00000000000..7659b833643 --- /dev/null +++ b/src/main/runtime/rpc/unpaired-device-auth-throttle.ts @@ -0,0 +1,47 @@ +// Why: a desktop that lost its device registry (pre-v1.4.106 pairing-path bug) +// rejects previously-paired phones with 4001 forever while both ends stay +// silent. This gate turns that repeated failure pattern into ONE user-facing +// signal per runtime session, without firing on a single stray probe. + +const DEFAULT_FAILURE_THRESHOLD = 3 +const DEFAULT_WINDOW_MS = 60_000 + +export type UnpairedDeviceAuthThrottleOptions = { + onTrigger: () => void + // Failures within windowMs needed before onTrigger fires (default 3 in 60s). + failureThreshold?: number + windowMs?: number + now?: () => number +} + +export class UnpairedDeviceAuthThrottle { + private readonly onTrigger: () => void + private readonly failureThreshold: number + private readonly windowMs: number + private readonly now: () => number + private readonly failureTimestamps: number[] = [] + private triggered = false + + constructor(options: UnpairedDeviceAuthThrottleOptions) { + this.onTrigger = options.onTrigger + this.failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD + this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS + this.now = options.now ?? Date.now + } + + recordFailure(): void { + if (this.triggered) { + return + } + const now = this.now() + this.failureTimestamps.push(now) + while (this.failureTimestamps.length > 0 && now - this.failureTimestamps[0]! > this.windowMs) { + this.failureTimestamps.shift() + } + if (this.failureTimestamps.length >= this.failureThreshold) { + this.triggered = true + this.failureTimestamps.length = 0 + this.onTrigger() + } + } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 10b366cbd96..058dbeba7aa 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -16,6 +16,7 @@ import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port- import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle' import { MobileSocketWiring, type AuthenticatedMobileSocket, @@ -437,6 +438,8 @@ export class OrcaRuntimeRpcServer { private transports: RuntimeTransportMetadata[] = [] private mobileSocketWiring: MobileSocketWiring | null = null private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + private onUnpairedDeviceAuthFailure: (() => void) | null = null + private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null private readonly binaryStreamHandlers = new Map< string, Map void> @@ -521,6 +524,11 @@ export class OrcaRuntimeRpcServer { return updated } + // Why: only the desktop shell can surface UI; headless serve leaves this unset. + setOnUnpairedDeviceAuthFailure(callback: (() => void) | null): void { + this.onUnpairedDeviceAuthFailure = callback + } + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { this.mobileRelayPairingProvider = provider } @@ -888,6 +896,10 @@ export class OrcaRuntimeRpcServer { ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}), ...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {}) }) + // Why: session-scoped (recreated per start) so each desktop launch may notify once. + this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({ + onTrigger: () => this.onUnpairedDeviceAuthFailure?.() + }) const mobileSocketWiring = new MobileSocketWiring({ deviceRegistry: pairingIdentity.deviceRegistry, e2eeKeypair: pairingIdentity.e2eeKeypair, @@ -924,6 +936,12 @@ export class OrcaRuntimeRpcServer { if (!hasOtherConnections) { this.runtime.onClientDisconnected(socket.device.deviceToken) } + }, + // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. + onUnpairedDeviceAuthFailure: (metadata) => { + if (metadata.transport === 'direct') { + this.unpairedDeviceAuthThrottle?.recordFailure() + } } }) mobileSocketWiring.attachTransport(wsTransport) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 37a7d39d1de..8f37dec692c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3279,6 +3279,10 @@ export type PreloadApi = { isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> getRelayStatus: () => Promise<{ status: MobileRelayStatus }> onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void + /** Consumes an auth-failure notification that arrived before the renderer listener mounted. */ + consumePendingUnpairedDeviceAuthFailure?: () => Promise + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure?: (callback: () => void) => () => void } speech: { getCatalog: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index af26f9c7ace..01332bfe23e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4368,6 +4368,16 @@ const api = { callback(status) ipcRenderer.on('mobile:relayStatusChanged', listener) return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + }, + + consumePendingUnpairedDeviceAuthFailure: (): Promise => + ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), + + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) + return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) } }, diff --git a/src/renderer/src/hooks/unpaired-device-auth-notification.test.ts b/src/renderer/src/hooks/unpaired-device-auth-notification.test.ts new file mode 100644 index 00000000000..f3da80cbcf2 --- /dev/null +++ b/src/renderer/src/hooks/unpaired-device-auth-notification.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' + +describe('subscribeToUnpairedDeviceAuthNotification', () => { + it('delivers a notification retained before the renderer subscribed', async () => { + const onNotification = vi.fn() + const unsubscribe = subscribeToUnpairedDeviceAuthNotification( + { + consumePendingUnpairedDeviceAuthFailure: vi.fn().mockResolvedValue(true), + onUnpairedDeviceAuthFailure: vi.fn(() => vi.fn()) + }, + onNotification + ) + + await Promise.resolve() + + expect(onNotification).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('consumes concurrent mount and live signals only once', async () => { + const onNotification = vi.fn() + const listenerState: { liveListener?: () => void } = {} + const consumePendingUnpairedDeviceAuthFailure = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false) + const unsubscribe = subscribeToUnpairedDeviceAuthNotification( + { + consumePendingUnpairedDeviceAuthFailure, + onUnpairedDeviceAuthFailure: (listener) => { + listenerState.liveListener = listener + return vi.fn() + } + }, + onNotification + ) + + listenerState.liveListener?.() + await Promise.resolve() + + expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledTimes(2) + expect(onNotification).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('keeps live delivery with an older preload that has no consume API', () => { + const onNotification = vi.fn() + const listenerState: { liveListener?: () => void } = {} + const unsubscribe = subscribeToUnpairedDeviceAuthNotification( + { + onUnpairedDeviceAuthFailure: (listener) => { + listenerState.liveListener = listener + return vi.fn() + } + }, + onNotification + ) + + expect(onNotification).not.toHaveBeenCalled() + listenerState.liveListener?.() + expect(onNotification).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('finishes an in-flight one-shot claim across StrictMode cleanup', async () => { + const pendingState: { resolve?: (pending: boolean) => void } = {} + const onNotification = vi.fn() + const unsubscribe = subscribeToUnpairedDeviceAuthNotification( + { + consumePendingUnpairedDeviceAuthFailure: () => + new Promise((resolve) => { + pendingState.resolve = resolve + }) + }, + onNotification + ) + + unsubscribe() + pendingState.resolve?.(true) + await Promise.resolve() + + expect(onNotification).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/hooks/unpaired-device-auth-notification.ts b/src/renderer/src/hooks/unpaired-device-auth-notification.ts new file mode 100644 index 00000000000..5ada9cd7fd4 --- /dev/null +++ b/src/renderer/src/hooks/unpaired-device-auth-notification.ts @@ -0,0 +1,37 @@ +type UnpairedDeviceAuthNotificationApi = { + consumePendingUnpairedDeviceAuthFailure?: () => Promise + onUnpairedDeviceAuthFailure?: (callback: () => void) => () => void +} + +export function subscribeToUnpairedDeviceAuthNotification( + api: UnpairedDeviceAuthNotificationApi | undefined, + onNotification: () => void +): () => void { + const consume = (notifyIfUnavailable: boolean): void => { + if (!api?.consumePendingUnpairedDeviceAuthFailure) { + if (notifyIfUnavailable) { + onNotification() + } + return + } + void api + .consumePendingUnpairedDeviceAuthFailure() + .then((pending) => { + if (pending) { + onNotification() + } + }) + .catch(() => { + if (notifyIfUnavailable) { + onNotification() + } + }) + } + + const unsubscribe = api?.onUnpairedDeviceAuthFailure?.(() => consume(true)) ?? (() => {}) + // Why: main may reach the throttle while the renderer is still mounting; pull that retained one-shot after subscribing. + consume(false) + + // Why: an in-flight true result has already consumed main's one-shot, so cleanup detaches events but lets that claim finish (including StrictMode remounts). + return unsubscribe +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d5b0a6dfb3d..94027336884 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -87,6 +87,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' import { applyRuntimeEnvironmentSshStateChanged, hydrateRuntimeEnvironmentSshState @@ -1177,6 +1178,36 @@ export function useIpcEvents(): void { }) ?? (() => {}) ) + // Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as + // "phone won't connect" with no clue on either end; main throttles to once per session. + unsubs.push( + subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => { + toast.warning( + translate( + 'auto.hooks.useIpcEvents.ef223fbb6b', + 'A device tried to connect but is not paired' + ), + { + id: 'unpaired-device-auth-failure', + description: translate( + 'auto.hooks.useIpcEvents.11992d0337', + 'If this was your phone or another Orca client, re-pair it from Settings → Mobile.' + ), + // Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed. + duration: Infinity, + action: { + label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'), + onClick: () => { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'mobile', repoId: null }) + store.openSettingsPage() + } + } + } + ) + }) + ) + unsubs.push( window.api.ui.onOpenFeatureTour(() => { useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c98fc491d3e..635a6fc6dcf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -679,7 +679,10 @@ "2fe88c2e06": "Remote workspace sync unavailable", "2ec42e1c52": "No remote workspace yet", "88214a785b": "Workspace sync waited for local session hydration and timed out", - "4f78ba5885": "Workspace synced" + "4f78ba5885": "Workspace synced", + "ef223fbb6b": "A device tried to connect but is not paired", + "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", + "6573cfe955": "Open Mobile Settings" }, "useSettingsNavigationMetadata": { "4a728cd56b": "New features that are still taking shape. Give them a try.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9ca16632d96..011a20a2529 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -656,7 +656,10 @@ "2fe88c2e06": "La sincronización del espacio de trabajo remoto no está disponible", "2ec42e1c52": "Aún no hay espacio de trabajo remoto", "88214a785b": "La sincronización del espacio de trabajo esperó a que la sesión local terminara de cargarse y agotó el tiempo de espera", - "4f78ba5885": "Espacio de trabajo sincronizado" + "4f78ba5885": "Espacio de trabajo sincronizado", + "ef223fbb6b": "A device tried to connect but is not paired", + "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", + "6573cfe955": "Open Mobile Settings" }, "useSettingsNavigationMetadata": { "4a728cd56b": "Nuevas funciones que aún están tomando forma. Pruébalas.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2dd2bb33f8b..47ec9aa02ac 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -656,7 +656,10 @@ "2fe88c2e06": "リモートワークスペースの同期は利用できません", "2ec42e1c52": "リモートワークスペースはまだありません", "88214a785b": "ワークスペースの同期はローカル セッションのハイドレーションを待機し、タイムアウトになりました", - "4f78ba5885": "ワークスペースが同期されました" + "4f78ba5885": "ワークスペースが同期されました", + "ef223fbb6b": "A device tried to connect but is not paired", + "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", + "6573cfe955": "Open Mobile Settings" }, "useSettingsNavigationMetadata": { "4a728cd56b": "まだ形になりつつある新機能。試してみてください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 37eed65e9ee..ecbf6544b5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -656,7 +656,10 @@ "2fe88c2e06": "원격 워크스페이스 동기화를 사용할 수 없습니다.", "2ec42e1c52": "아직 원격 워크스페이스가 없습니다.", "88214a785b": "워크스페이스 동기화가 로컬 세션 하이드레이션을 기다렸다가 시간 초과되었습니다.", - "4f78ba5885": "워크스페이스가 동기화되었습니다." + "4f78ba5885": "워크스페이스가 동기화되었습니다.", + "ef223fbb6b": "A device tried to connect but is not paired", + "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", + "6573cfe955": "Open Mobile Settings" }, "useSettingsNavigationMetadata": { "4a728cd56b": "아직 구체화되고 있는 새로운 기능입니다. 한번 시도해 보세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d8c99517fff..72ece536917 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -656,7 +656,10 @@ "2fe88c2e06": "远程工作区同步不可用", "2ec42e1c52": "还没有远程工作区", "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", - "4f78ba5885": "工作区已同步" + "4f78ba5885": "工作区已同步", + "ef223fbb6b": "A device tried to connect but is not paired", + "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", + "6573cfe955": "Open Mobile Settings" }, "useSettingsNavigationMetadata": { "4a728cd56b": "仍在完善中的新功能。尝试一下。", diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 27c28b36f70..348c9566128 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -799,7 +799,9 @@ function createWebPreloadApi(): Partial { isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), - onRelayStatusChanged: () => noopUnsubscribe + onRelayStatusChanged: () => noopUnsubscribe, + consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), + onUnpairedDeviceAuthFailure: () => noopUnsubscribe }, telemetryTrack: () => Promise.resolve(), telemetrySetOptIn: () => Promise.resolve(), From 034aeb15e45b90bdeef02d386023fc3295fb3038 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:11:19 -0700 Subject: [PATCH 07/29] feat(editor): seed Find from selected text (#9982) --- .../src/components/editor/DiffSectionBody.tsx | 7 ++----- src/renderer/src/components/editor/DiffViewer.tsx | 7 ++----- src/renderer/src/components/editor/MonacoEditor.tsx | 7 ++----- .../components/editor/monaco-find-options.test.ts | 12 ++++++++++++ .../src/components/editor/monaco-find-options.ts | 7 +++++++ 5 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/editor/monaco-find-options.test.ts create mode 100644 src/renderer/src/components/editor/monaco-find-options.ts diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 8a1dc3cef4d..5bf44334794 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -10,6 +10,7 @@ import type { DiffSection } from './diff-section-types' import { translate } from '@/i18n/i18n' import { LargeDiffFallback } from './LargeDiffFallback' import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options' +import { monacoFindOptions } from './monaco-find-options' const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) @@ -198,11 +199,7 @@ export function DiffSectionBody({ renderOverviewRuler: false, scrollbar: combinedDiffSectionScrollbarOptions, hideUnchangedRegions: { enabled: true }, - find: { - addExtraSpaceOnTop: false, - autoFindInSelection: 'never', - seedSearchStringFromSelection: 'never' - } + find: monacoFindOptions }} /> )} diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 5aed5c9670d..e2f364a2c81 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -26,6 +26,7 @@ import type { DiffViewerProps } from './diff-viewer-props' import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options' import { useDiffEditorRegistration } from './diff-navigation-context' import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state' +import { monacoFindOptions } from './monaco-find-options' export default function DiffViewer({ modelKey, @@ -429,11 +430,7 @@ export default function DiffViewer({ renderOverviewRuler: true, scrollbar: diffEditorScrollbarOptions, padding: { top: 0 }, - find: { - addExtraSpaceOnTop: false, - autoFindInSelection: 'never', - seedSearchStringFromSelection: 'never' - } + find: monacoFindOptions }} /> )} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index dbb5bd0b241..c6996ad341e 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -65,6 +65,7 @@ import { isMonacoAutoHeightCapped } from './monaco-auto-height' import { installMonacoE2EProbe } from './monaco-e2e-probe' +import { monacoFindOptions } from './monaco-find-options' type MonacoEditorProps = { fileId: string @@ -848,11 +849,7 @@ export default function MonacoEditor({ smoothScrolling: true, cursorSmoothCaretAnimation: 'off', padding: { top: 0 }, - find: { - addExtraSpaceOnTop: false, - autoFindInSelection: 'never', - seedSearchStringFromSelection: 'never' - }, + find: monacoFindOptions, // Why: Monaco owns its rendered line surface, so align its selection-clipboard with the app opt-out (the global DOM hook can't). selectionClipboard: settings?.primarySelectionMiddleClickPaste ?? isLinuxUserAgent() }} diff --git a/src/renderer/src/components/editor/monaco-find-options.test.ts b/src/renderer/src/components/editor/monaco-find-options.test.ts new file mode 100644 index 00000000000..8bf024a5ecb --- /dev/null +++ b/src/renderer/src/components/editor/monaco-find-options.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { monacoFindOptions } from './monaco-find-options' + +describe('monacoFindOptions', () => { + it('seeds Find only from an explicit selection without changing its layout or scope', () => { + expect(monacoFindOptions).toEqual({ + addExtraSpaceOnTop: false, + autoFindInSelection: 'never', + seedSearchStringFromSelection: 'selection' + }) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-find-options.ts b/src/renderer/src/components/editor/monaco-find-options.ts new file mode 100644 index 00000000000..fa0e0589f3d --- /dev/null +++ b/src/renderer/src/components/editor/monaco-find-options.ts @@ -0,0 +1,7 @@ +import type { editor } from 'monaco-editor' + +export const monacoFindOptions = { + addExtraSpaceOnTop: false, + autoFindInSelection: 'never', + seedSearchStringFromSelection: 'selection' +} satisfies editor.IEditorFindOptions From 4468d54f3ce04449bff3128949b17430cc359946 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:12:55 -0700 Subject: [PATCH 08/29] perf(mobile): gate host polling on foreground/background (#9857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(mobile): gate host polling on foreground The mobile host screen ran two 3s polls (routed + embedded), each firing worktree.ps AND repo.list, with no foreground/background gate — so a connected phone kept pinging every 3s (worktree.ps is a full multi-repo process scan) plus a radio wakeup, including brief background windows while the socket stays parked. Consolidate both into one startHostWorktreeRefresh lifecycle and AppState-gate the interval so BOTH polls stop while backgrounded and refresh immediately on foreground return. worktree.ps keeps its 3s cadence while foregrounded (it carries live agent status/preview/unread that no push event replaces). repo.list stays on the interval as an AppState-gated, self-throttling (REPO_METADATA_REFRESH_MS=60s) convergence safety-net — desktop Settings repo edits notify only the renderer, not the runtime clientEvents stream, so it can't be made purely event-driven without going stale — and additionally gets a reposChanged/worktreesChanged fast-path and reconnect-replay refetch. Verified in a deps-installed mobile checkout: full mobile suite 2232 pass, typecheck, oxlint (within the frozen max-lines budget), and oxfmt --check all clean. Co-authored-by: Orca * chore(mobile): drop stale fetchRepoMetadata dep from the reconnect effect Address CodeRabbit nitpick: the reconnect effect no longer calls fetchRepoMetadata (that refetch moved into startHostWorktreeRefresh), so it shouldn't remain in the effect's dependency array. Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 112 ++++++++------- mobile/src/transport/use-worktree-resync.ts | 10 +- .../worktree/host-worktree-refresh.test.ts | 127 ++++++++++++++++++ mobile/src/worktree/host-worktree-refresh.ts | 88 ++++++++++++ 4 files changed, 276 insertions(+), 61 deletions(-) create mode 100644 mobile/src/worktree/host-worktree-refresh.test.ts create mode 100644 mobile/src/worktree/host-worktree-refresh.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 860106aa1b6..512dcad773a 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -38,6 +38,7 @@ import { useForceReconnect } from '../../../src/transport/client-context' import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' +import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' import { useLastConnectedAt, useReconnectAttempt @@ -140,7 +141,8 @@ export function HostScreen({ const lastConnectedAt = useLastConnectedAt(hostId) const clientRef = useRef(null) const fetchWorktreesInFlightRef = useRef(false) - const fetchRepoMetadataInFlightRef = useRef(false) + const fetchRepoMetadataInFlightRef = useRef(new WeakSet()) + const fetchRepoMetadataPendingRef = useRef(new WeakSet()) const repoMetadataFetchedAtRef = useRef(0) const newWorktreeModalRef = useRef<{ open: () => void }>(null) const newWorktreeModalVisibleRef = useRef(false) @@ -351,48 +353,54 @@ export function HostScreen({ }, [hostId]) const fetchRepoMetadata = useCallback( - async (options: { force?: boolean } = {}) => { + async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => { if (!client || connState !== 'connected' || !hostId) { return } - if (fetchRepoMetadataInFlightRef.current) { + if (fetchRepoMetadataInFlightRef.current.has(client)) { + if (options.queueIfInFlight) { + fetchRepoMetadataPendingRef.current.add(client) + } return } const now = Date.now() if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) { return } - fetchRepoMetadataInFlightRef.current = true + fetchRepoMetadataInFlightRef.current.add(client) const requestClient = client, requestHostId = hostId try { - const repoResponse = await requestClient.sendRequest('repo.list') - if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) { - return - } - const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] } - repoMetadataFetchedAtRef.current = Date.now() - setCachedRepos(requestHostId, repoResult.repos) - setRepoColorsByName( - new Map( - repoResult.repos.map((repo) => [ - repo.displayName, - repo.badgeColor || repoColor(repo.displayName) - ]) - ) - ) - setRepoIconsByName( - new Map( - repoResult.repos.flatMap((repo) => - repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : [] + do { + fetchRepoMetadataPendingRef.current.delete(requestClient) + const repoResponse = await requestClient.sendRequest('repo.list') + if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) { + return + } + const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] } + repoMetadataFetchedAtRef.current = Date.now() + setCachedRepos(requestHostId, repoResult.repos) + setRepoColorsByName( + new Map( + repoResult.repos.map((repo) => [ + repo.displayName, + repo.badgeColor || repoColor(repo.displayName) + ]) ) ) - ) - setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id]))) + setRepoIconsByName( + new Map( + repoResult.repos.flatMap((repo) => + repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : [] + ) + ) + ) + setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id]))) + } while (fetchRepoMetadataPendingRef.current.has(requestClient)) } catch { - // Repo metadata is decorative; the next throttled refresh can retry. + // Repo metadata is decorative; the next refresh can retry. } finally { - fetchRepoMetadataInFlightRef.current = false + fetchRepoMetadataInFlightRef.current.delete(requestClient) } }, [client, connState, hostId] @@ -489,39 +497,29 @@ export function HostScreen({ }, []) ) - useFocusEffect( - useCallback(() => { - // The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below. - if (embedded || connState !== 'connected') { - return - } - void fetchWorktrees() - void fetchRepoMetadata() - // Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh. - void syncViewSettingsFromDesktop() - // Why: React Navigation keeps prior screens mounted; only poll while this route is visible. - const interval = setInterval(() => { - void fetchWorktrees() - void fetchRepoMetadata() - }, 3000) - return () => clearInterval(interval) - }, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) - ) - - // Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect. - useEffect(() => { - if (!embedded || connState !== 'connected') { + const startWorktreeRefresh = useCallback(() => { + if (!client || connState !== 'connected') { return } - void fetchWorktrees() - void fetchRepoMetadata() void syncViewSettingsFromDesktop() - const interval = setInterval(() => { - void fetchWorktrees() - void fetchRepoMetadata() - }, 3000) - return () => clearInterval(interval) - }, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) + return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata }) + }, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) + + useFocusEffect( + useCallback(() => { + // The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below. + if (!embedded) { + return startWorktreeRefresh() + } + }, [embedded, startWorktreeRefresh]) + ) + + // Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect. + useEffect(() => { + if (embedded) { + return startWorktreeRefresh() + } + }, [embedded, startWorktreeRefresh]) // Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest. const { refreshing, onRefresh } = useWorktreeResync({ diff --git a/mobile/src/transport/use-worktree-resync.ts b/mobile/src/transport/use-worktree-resync.ts index ef035cd79a6..70275b065ba 100644 --- a/mobile/src/transport/use-worktree-resync.ts +++ b/mobile/src/transport/use-worktree-resync.ts @@ -10,7 +10,7 @@ export function useWorktreeResync(args: { client: RpcClient | null connState: ConnectionState fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise - fetchRepoMetadata: () => Promise + fetchRepoMetadata: (options?: { force?: boolean; queueIfInFlight?: boolean }) => Promise }): { refreshing: boolean; onRefresh: () => Promise } { const { client, connState, fetchWorktrees, fetchRepoMetadata } = args @@ -22,9 +22,11 @@ export function useWorktreeResync(args: { prevConnStateRef.current = connState if (prev !== 'connected' && connState === 'connected' && client) { void fetchWorktrees({ allowDuringModal: true }) - void fetchRepoMetadata() } - }, [connState, client, fetchWorktrees, fetchRepoMetadata]) + // Why: repo metadata refetch on reconnect is owned by startHostWorktreeRefresh (mount + + // reposChanged + stream replay); this effect only refetches worktrees, so fetchRepoMetadata + // is intentionally not a dependency here. + }, [connState, client, fetchWorktrees]) const [refreshing, setRefreshing] = useState(false) // Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache. @@ -35,7 +37,7 @@ export function useWorktreeResync(args: { setRefreshing(true) try { await fetchWorktrees({ allowDuringModal: true }) - await fetchRepoMetadata() + await fetchRepoMetadata({ force: true }) } finally { setRefreshing(false) } diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts new file mode 100644 index 00000000000..38ea6ad4aec --- /dev/null +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { startHostWorktreeRefresh } from './host-worktree-refresh' + +const appState = vi.hoisted(() => ({ + currentState: 'active', + listener: null as ((state: string) => void) | null, + remove: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { + get currentState() { + return appState.currentState + }, + addEventListener: (_event: string, listener: (state: string) => void) => { + appState.listener = listener + return { remove: appState.remove } + } + } +})) + +describe('startHostWorktreeRefresh', () => { + let eventListener: ((payload: unknown) => void) | null + let fetchWorktrees: ReturnType + let fetchRepoMetadata: ReturnType + let unsubscribe: ReturnType + let stop: (() => void) | null + + beforeEach(() => { + vi.useFakeTimers() + appState.currentState = 'active' + appState.listener = null + appState.remove.mockClear() + eventListener = null + fetchWorktrees = vi.fn().mockResolvedValue(undefined) + fetchRepoMetadata = vi.fn().mockResolvedValue(undefined) + unsubscribe = vi.fn() + stop = null + }) + + function start(): void { + const client = { + subscribe: vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + eventListener = listener + return unsubscribe + } + ) + } as unknown as RpcClient + stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata }) + } + + afterEach(() => { + stop?.() + vi.useRealTimers() + }) + + it('keeps the worktree poll active but skips ticks while backgrounded', async () => { + start() + expect(fetchWorktrees).toHaveBeenCalledTimes(1) + + appState.currentState = 'background' + await vi.advanceTimersByTimeAsync(6_000) + expect(fetchWorktrees).toHaveBeenCalledTimes(1) + + appState.currentState = 'active' + await vi.advanceTimersByTimeAsync(3_000) + expect(fetchWorktrees).toHaveBeenCalledTimes(2) + }) + + it('refreshes both snapshots immediately on foreground return', () => { + start() + fetchWorktrees.mockClear() + fetchRepoMetadata.mockClear() + + appState.currentState = 'active' + appState.listener?.('active') + + expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true }) + expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true }) + }) + + it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => { + start() + // Mount force-fetch. + expect(fetchRepoMetadata).toHaveBeenCalledTimes(1) + + // Foregrounded: repo.list rides the interval as a convergence safety-net for desktop + // Settings edits that never emit a runtime reposChanged (the callee self-throttles). + await vi.advanceTimersByTimeAsync(9_000) + expect(fetchWorktrees).toHaveBeenCalledTimes(4) + expect(fetchRepoMetadata).toHaveBeenCalledTimes(4) + + // Backgrounded: neither snapshot is polled. + fetchWorktrees.mockClear() + fetchRepoMetadata.mockClear() + appState.currentState = 'background' + await vi.advanceTimersByTimeAsync(9_000) + expect(fetchWorktrees).not.toHaveBeenCalled() + expect(fetchRepoMetadata).not.toHaveBeenCalled() + }) + + it('force-refreshes repo metadata on reposChanged', () => { + start() + fetchRepoMetadata.mockClear() + + eventListener?.({ type: 'reposChanged' }) + + expect(fetchRepoMetadata).toHaveBeenCalledOnce() + expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) + }) + + it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => { + start() + fetchWorktrees.mockClear() + fetchRepoMetadata.mockClear() + + eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' }) + expect(fetchWorktrees).toHaveBeenCalledTimes(1) + + eventListener?.({ type: 'ready', subscriptionId: 'events-1' }) + eventListener?.({ type: 'ready', subscriptionId: 'events-2' }) + expect(fetchWorktrees).toHaveBeenCalledTimes(2) + expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) + }) +}) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts new file mode 100644 index 00000000000..7fa3161740a --- /dev/null +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -0,0 +1,88 @@ +import { AppState } from 'react-native' +import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' +import type { RpcClient } from '../transport/rpc-client' + +const WORKTREE_REFRESH_MS = 3000 + +type WorktreeRefreshOptions = { allowDuringModal?: boolean } +type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean } + +type HostWorktreeRefreshArgs = { + client: RpcClient + fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise + fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise +} + +export function startHostWorktreeRefresh({ + client, + fetchWorktrees, + fetchRepoMetadata +}: HostWorktreeRefreshArgs): () => void { + let stale = false + let eventStreamReady = false + + const refreshOnForeground = (): void => { + if (AppState.currentState !== 'active') { + return + } + void fetchWorktrees({ allowDuringModal: true }) + void fetchRepoMetadata({ queueIfInFlight: true }) + } + + const appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refreshOnForeground() + } + }) + const interval = setInterval(() => { + if (AppState.currentState !== 'active') { + return + } + void fetchWorktrees() + // Why: desktop Settings repo edits (icon/color/name, repo removal) notify only the + // renderer IPC, not the runtime clientEvents stream, so `reposChanged` never reaches + // mobile. Keep a periodic repo.list as the convergence safety-net; fetchRepoMetadata + // self-throttles to REPO_METADATA_REFRESH_MS (60s), so this is ~1 request/min while + // foregrounded — the AppState gate is what removes the waste (both stop while backgrounded). + void fetchRepoMetadata() + }, WORKTREE_REFRESH_MS) + const unsubscribe = client.subscribe( + 'runtime.clientEvents.subscribe', + null, + (payload: unknown) => { + if (stale || !payload || typeof payload !== 'object') { + return + } + const event = payload as RuntimeClientEventStreamMessage | { type: 'error' } + if (event.type === 'ready') { + const replayedAfterReconnect = eventStreamReady + eventStreamReady = true + if (replayedAfterReconnect) { + // Why: client events are not queued while disconnected, so re-read both snapshots after replay. + void fetchWorktrees() + void fetchRepoMetadata({ force: true, queueIfInFlight: true }) + } + return + } + if (event.type === 'end' || event.type === 'error') { + eventStreamReady = false + return + } + if (event.type === 'reposChanged') { + void fetchRepoMetadata({ force: true, queueIfInFlight: true }) + } else if (event.type === 'worktreesChanged') { + void fetchWorktrees() + } + } + ) + + void fetchWorktrees() + void fetchRepoMetadata({ force: true, queueIfInFlight: true }) + + return () => { + stale = true + clearInterval(interval) + appStateSubscription.remove() + unsubscribe() + } +} From 76f5b8318cd979b2b1ac9db18ed70fd2b147585c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:12:59 -0700 Subject: [PATCH 09/29] fix(mobile): pause session polling in background (#9875) Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 23 ++++++++++++++----- .../mobile-session-startup-source.test.ts | 20 ++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8b032c3ad99..367c4d8d817 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -2706,14 +2706,25 @@ export default function SessionScreen() { if (connState !== 'connected') { return } - void fetchSessionTabs() - void fetchTerminals() - // Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden. - const interval = setInterval(() => { + const refreshOnForeground = () => { + if (AppState.currentState !== 'active') { + return + } void fetchSessionTabs() void fetchTerminals() - }, 2000) - return () => clearInterval(interval) + } + const appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refreshOnForeground() + } + }) + // Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded. + const interval = setInterval(refreshOnForeground, 2000) + refreshOnForeground() + return () => { + clearInterval(interval) + appStateSubscription.remove() + } }, [connState, fetchSessionTabs, fetchTerminals]) ) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index bad6bd330b1..a86fe393e29 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -29,6 +29,26 @@ describe('mobile session startup', () => { expect(autoCreateEffect).toContain('void handleCreateTerminal()') }) + it('stops fallback list polling in the background and reconciles on resume', () => { + const pollEffect = sliceBetween( + "const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')", + '// Why: pick up Settings → Terminal text size on return' + ) + + expect(pollEffect).toContain( + "if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()" + ) + expect(pollEffect).toContain('void fetchTerminals()') + expect(pollEffect).toContain("AppState.addEventListener('change'") + expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()") + expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)') + expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan( + pollEffect.indexOf("AppState.addEventListener('change'") + ) + expect(pollEffect).toContain('clearInterval(interval)') + expect(pollEffect).toContain('appStateSubscription.remove()') + }) + it('loads session tabs without waiting for desktop activation', () => { const startupEffect = sliceBetween( 'void (async () => {', From a3d6f842867debb5b482191eca8c83c762f08f7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:13:02 -0700 Subject: [PATCH 10/29] fix(mobile): pause relative-time clocks when hidden (#9886) Co-authored-by: Orca --- mobile/app/h/[hostId]/accounts.tsx | 19 +++--- mobile/src/hooks/use-now.test.ts | 101 +++++++++++++++++++++++++++++ mobile/src/hooks/use-now.ts | 52 +++++++++++++-- 3 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 mobile/src/hooks/use-now.test.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 652dfffee75..3361e415030 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -9,13 +9,14 @@ import { Alert } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../../../src/transport/host-store' import { useHostClient } from '../../../src/transport/client-context' import type { RpcSuccess } from '../../../src/transport/types' import { colors, spacing } from '../../../src/theme/mobile-theme' import { styles } from './accounts-screen-styles' +import { useNow } from '../../../src/hooks/use-now' import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' import { type AccountsSnapshot, @@ -40,14 +41,16 @@ export default function AccountsScreen() { const [error, setError] = useState(null) const [refreshing, setRefreshing] = useState(false) const [busyAccountId, setBusyAccountId] = useState(null) + const [clockEnabled, setClockEnabled] = useState(false) - // Why: the reset countdown must stay fresh while the screen sits open — - // snapshot pushes only arrive when the desktop's rate-limit poll completes. - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 60_000) - return () => clearInterval(id) - }, []) + useFocusEffect( + useCallback(() => { + setClockEnabled(true) + return () => setClockEnabled(false) + }, []) + ) + // Why: snapshot pushes only arrive when the desktop's rate-limit poll completes. + const now = useNow(60_000, clockEnabled) useEffect(() => { if (!hostId) { diff --git a/mobile/src/hooks/use-now.test.ts b/mobile/src/hooks/use-now.test.ts new file mode 100644 index 00000000000..c3fe56a980b --- /dev/null +++ b/mobile/src/hooks/use-now.test.ts @@ -0,0 +1,101 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' + +const appState = vi.hoisted(() => ({ + current: 'active', + listener: null as ((nextState: string) => void) | null, + remove: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { + get currentState(): string { + return appState.current + }, + addEventListener: (_event: string, listener: (nextState: string) => void) => { + appState.listener = listener + return { remove: appState.remove } + } + } +})) + +import { useNow } from './use-now' + +describe('useNow', () => { + let renderer: ReactTestRenderer | null = null + let latest = 0 + let consoleSpy: MockInstance + + function Harness({ enabled = true }: { enabled?: boolean }): null { + latest = useNow(1_000, enabled) + return null + } + + function changeAppState(nextState: string): void { + act(() => { + appState.current = nextState + appState.listener?.(nextState) + }) + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + globalThis.IS_REACT_ACT_ENVIRONMENT = true + appState.current = 'active' + appState.listener = null + appState.remove.mockClear() + latest = 0 + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + act(() => { + renderer = create(createElement(Harness)) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + consoleSpy.mockRestore() + }) + + it('ticks while active, pauses in the background, and refreshes immediately on resume', () => { + expect(latest).toBe(1_000) + + act(() => vi.advanceTimersByTime(1_000)) + expect(latest).toBe(2_000) + + changeAppState('background') + act(() => vi.advanceTimersByTime(5_000)) + expect(latest).toBe(2_000) + + changeAppState('active') + expect(latest).toBe(7_000) + + act(() => vi.advanceTimersByTime(1_000)) + expect(latest).toBe(8_000) + }) + + it('stops while disabled and refreshes immediately when re-enabled', () => { + act(() => renderer?.update(createElement(Harness, { enabled: false }))) + act(() => vi.advanceTimersByTime(5_000)) + expect(latest).toBe(1_000) + + act(() => renderer?.update(createElement(Harness, { enabled: true }))) + expect(latest).toBe(6_000) + }) + + it('removes the shared AppState listener after the last caller unmounts', () => { + act(() => renderer?.unmount()) + renderer = null + + expect(appState.remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/hooks/use-now.ts b/mobile/src/hooks/use-now.ts index 1114ff6998b..df3029a4e60 100644 --- a/mobile/src/hooks/use-now.ts +++ b/mobile/src/hooks/use-now.ts @@ -1,14 +1,52 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { AppState, type AppStateStatus } from 'react-native' -// One shared interval per caller, mirroring desktop's useNow: relative -// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list -// owns a single tick that drives every visible agent row, rather than each row -// running its own interval. -export function useNow(intervalMs = 30_000): number { +const appStateListeners = new Set<() => void>() +let currentAppState: AppStateStatus | null = AppState.currentState +let appStateSubscription: ReturnType | null = null + +function subscribeToAppState(listener: () => void): () => void { + appStateListeners.add(listener) + if (!appStateSubscription) { + currentAppState = AppState.currentState + appStateSubscription = AppState.addEventListener('change', (nextState) => { + currentAppState = nextState + appStateListeners.forEach((notify) => notify()) + }) + } + + return () => { + appStateListeners.delete(listener) + if (appStateListeners.size === 0) { + appStateSubscription?.remove() + appStateSubscription = null + } + } +} + +function isAppActive(): boolean { + return (appStateSubscription ? currentAppState : AppState.currentState) === 'active' +} + +// A list-level caller's single tick drives every visible relative-time label. +export function useNow(intervalMs = 30_000, enabled = true): number { + const appActive = useSyncExternalStore(subscribeToAppState, isAppActive, isAppActive) + const running = appActive && enabled const [now, setNow] = useState(() => Date.now()) + const wasRunningRef = useRef(running) + useEffect(() => { + const resumed = running && !wasRunningRef.current + wasRunningRef.current = running + if (!running) { + return + } + if (resumed) { + setNow(Date.now()) + } const id = setInterval(() => setNow(Date.now()), intervalMs) return () => clearInterval(id) - }, [intervalMs]) + }, [intervalMs, running]) + return now } From c6d280348ad682263075c326e5ad502c922a6d62 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:13:06 -0700 Subject: [PATCH 11/29] perf(mobile): memoize worktree list rows (#9889) Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 12 +- mobile/src/components/WorktreeAgentRow.tsx | 5 +- mobile/src/components/WorktreeListRow.test.ts | 212 ++++++++++++++++++ mobile/src/components/WorktreeListRow.tsx | 5 +- 4 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 mobile/src/components/WorktreeListRow.test.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 512dcad773a..d88fb6699aa 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -738,15 +738,17 @@ export function HostScreen({ const toggleCollapsed = useCallback( (key: string) => { const next = new Set(viewStateRef.current.collapsedGroups) - if (next.has(key)) { - next.delete(key) - } else { + if (!next.delete(key)) { next.add(key) } persistViewSettings({ collapsedGroups: [...next] }) }, [persistViewSettings] ) + const toggleWorktreeLineage = useCallback( + (item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)), + [toggleCollapsed] + ) const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({ displayWorktrees, sortMode, @@ -1189,9 +1191,7 @@ export function HostScreen({ hideRepo={groupMode === 'repo'} onPress={openWorktreeSession} onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget} - onToggleLineage={(row) => - toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId)) - } + onToggleLineage={toggleWorktreeLineage} /> )} /> diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx index 509b3f6031c..8740d12eaed 100644 --- a/mobile/src/components/WorktreeAgentRow.tsx +++ b/mobile/src/components/WorktreeAgentRow.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { StyleSheet, Text, View } from 'react-native' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { colors, spacing } from '../theme/mobile-theme' @@ -18,7 +19,7 @@ type Props = { // One inline agent row: state dot → identity → last message/prompt → time ago. // Mirrors desktop DashboardAgentRow's compact in-card layout. -export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) { +function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) { const dotState = agentDotState(agent, now) const label = agentDisplayLabel(agent, now) const ts = formatTimeAgo(agent.stateStartedAt, now) @@ -37,6 +38,8 @@ export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) { ) } +export const WorktreeAgentRow = memo(WorktreeAgentRowComponent) + const styles = StyleSheet.create({ row: { flexDirection: 'row', diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts new file mode 100644 index 00000000000..9fe0aa11b02 --- /dev/null +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -0,0 +1,212 @@ +import { + createElement, + Fragment, + useCallback, + useState, + type Dispatch, + type SetStateAction +} from 'react' +import { Text } from 'react-native' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { WorktreeAgentRow } from './WorktreeAgentRow' +import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow' + +const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({ + agentSpinnerRender: vi.fn(), + agentStateDotRender: vi.fn() +})) + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Bell: 'Bell', + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', + GitBranch: 'GitBranch', + GitPullRequest: 'GitPullRequest' +})) + +vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() })) +vi.mock('./AgentSpinner', () => ({ + AgentSpinner: (props: unknown) => { + agentSpinnerRender(props) + return null + } +})) +vi.mock('./AgentStateDot', () => ({ + AgentStateDot: (props: unknown) => { + agentStateDotRender(props) + return null + } +})) +vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) +vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null })) +vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null })) +vi.mock('./WorktreeMetaGlyphs', () => ({ + prStateColor: () => '#000000', + WorktreeMetaGlyphs: () => null +})) + +type TestItem = WorktreeListRowItem & { + status: 'working' | 'active' | 'permission' | 'done' | 'inactive' + lastOutputAt: number +} + +const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const +let updateSibling: Dispatch> = () => undefined + +function ListRowHarness({ item, now }: { item: TestItem; now: number }) { + const [sibling, setSibling] = useState(0) + updateSibling = setSibling + const onPress = useCallback(() => undefined, []) + const onLongPress = useCallback(() => undefined, []) + const onToggleLineage = useCallback(() => undefined, []) + + // Sibling state changes re-render the harness without changing the row's props, + // exercising the row's React.memo bailout. + return createElement( + Fragment, + null, + createElement(Text, null, sibling), + createElement(WorktreeListRow, { + item, + isReadOnly: false, + now, + repoColor: '#000000', + repoIcon: stableRepoIcon, + hideRepo: false, + status: item.status, + onPress, + onLongPress, + onToggleLineage + }) + ) +} + +function agent(overrides: Partial = {}): RuntimeWorktreeAgentRow { + return { + paneKey: 'agent-1', + parentPaneKey: null, + state: 'working', + agentType: null, + prompt: 'Fix the list', + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 1_000, + updatedAt: 1_000, + ...overrides + } +} + +const baseItem: TestItem = { + worktreeId: 'worktree-1', + repo: 'orca', + branch: 'feature/mobile-list', + displayName: 'mobile-list', + liveTerminalCount: 1, + preview: 'Waiting', + unread: false, + linkedPR: null, + agents: [agent()], + status: 'active', + lastOutputAt: 1_000 +} + +describe('memoized worktree rows', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + agentSpinnerRender.mockClear() + agentStateDotRender.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('skips an unrelated parent render but updates for live item fields and time', async () => { + await act(async () => { + renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 })) + }) + expect(agentSpinnerRender).toHaveBeenCalledTimes(1) + + await act(async () => updateSibling((value) => value + 1)) + expect(agentSpinnerRender).toHaveBeenCalledTimes(1) + + let expectedRenders = 1 + const liveUpdates: TestItem[] = [ + { ...baseItem, preview: 'Running tests' }, + { ...baseItem, unread: true }, + { ...baseItem, lastOutputAt: 2_000 }, + { ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] }, + { ...baseItem, status: 'working' } + ] + for (const liveUpdate of liveUpdates) { + await act(async () => + renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 })) + ) + expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders) + await act(async () => + renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 })) + ) + expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders) + } + + await act(async () => + renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 })) + ) + expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders) + }) + + it('memoizes agent rows without hiding agent updates', async () => { + const firstAgent = agent() + await act(async () => { + renderer = create( + createElement(WorktreeAgentRow, { + agent: firstAgent, + depth: 0, + now: 2_000, + unvisited: false + }) + ) + }) + expect(agentStateDotRender).toHaveBeenCalledTimes(1) + + await act(async () => { + renderer!.update( + createElement(WorktreeAgentRow, { + agent: firstAgent, + depth: 0, + now: 2_000, + unvisited: false + }) + ) + }) + expect(agentStateDotRender).toHaveBeenCalledTimes(1) + + await act(async () => { + renderer!.update( + createElement(WorktreeAgentRow, { + agent: agent({ state: 'done', updatedAt: 2_000 }), + depth: 0, + now: 2_000, + unvisited: false + }) + ) + }) + expect(agentStateDotRender).toHaveBeenCalledTimes(2) + }) +}) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 1bb7835cfa7..74b716c2b30 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' 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' @@ -57,7 +58,7 @@ type Props = { onToggleLineage?: (item: T) => void } -export function WorktreeListRow({ +function WorktreeListRowComponent({ item, isReadOnly, now, @@ -195,6 +196,8 @@ export function WorktreeListRow({ ) } +export const WorktreeListRow = memo(WorktreeListRowComponent) as typeof WorktreeListRowComponent + const styles = StyleSheet.create({ worktreeRow: { flexDirection: 'row', From 11310eef63c0a7dc9dc3d816903420e0d82f83fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:18:59 -0700 Subject: [PATCH 12/29] fix(mobile): keep quick-commands button steady while capabilities load (#9979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep quick-commands button steady while capabilities load The tab-row quick-commands button only rendered once the capability probe resolved true, so it popped in after the row was already visible (and vanished during reconnect re-probes). Render it whenever support is not confirmed absent and disable it until the probe settles — pre-quick-commands hosts strip agentPrompt, so the action (not the button) must wait for confirmation. Confirmed-unsupported hosts still hide it entirely. * fix(mobile): explain unsupported quick commands on tap instead of hiding Per feedback on the disabled/hidden states: the button now always renders and stays tappable. Tapping against a desktop that confirmed no support shows "Desktop update required for quick commands" (mirroring the browser streaming copy); tapping while the capability probe is still resolving says to try again in a moment. The sheet still opens only once support is confirmed, since pre-quick-commands hosts strip agentPrompt. * docs(pr): add QA screenshots for quick-commands button states * test(mobile): lock quick-commands button stability Add a focused source-contract test for the always-mounted tab action and confirmed-support sheet gate. Keep the non-obvious safety comment concise, and remove PR screenshots now hosted as GitHub user attachments. * test(mobile): structurally guard quick-command action mount --- .../app/h/[hostId]/session/[worktreeId].tsx | 24 +++++-- ...uick-commands-tab-stability-source.test.ts | 66 +++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 mobile/src/session/quick-commands-tab-stability-source.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 367c4d8d817..11764a25189 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4525,14 +4525,24 @@ export default function SessionScreen() { >
- {quickCommandsSupported === true ? ( - { + if (quickCommandsSupported === true) { + setShowQuickCommands(true) + return } - onPress={() => setShowQuickCommands(true)} - /> - ) : null} + showToast( + quickCommandsSupported === false + ? 'Desktop update required for quick commands' + : 'Checking desktop capabilities — try again in a moment', + 1600 + ) + }} + />
)}
diff --git a/mobile/src/session/quick-commands-tab-stability-source.test.ts b/mobile/src/session/quick-commands-tab-stability-source.test.ts new file mode 100644 index 00000000000..5172db1e6b6 --- /dev/null +++ b/mobile/src/session/quick-commands-tab-stability-source.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const fileUrl = new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url) +const source = readFileSync(fileUrl, 'utf8') +const sourceFile = ts.createSourceFile( + fileUrl.href, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX +) + +function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] { + const matches: ts.JsxSelfClosingElement[] = [] + + function visit(node: ts.Node): void { + if ( + ts.isJsxSelfClosingElement(node) && + node.tagName.getText(sourceFile) === 'QuickCommandsTabButton' + ) { + matches.push(node) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return matches +} + +function getQuickCommandsTabSource(): string { + const start = source.indexOf('accessibilityLabel="New tab"') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('{/* Content-row host', start) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('quick-commands tab stability', () => { + it('keeps the button mounted while preserving the capability gate', () => { + const tabSource = getQuickCommandsTabSource() + const buttons = findQuickCommandsTabButtons() + + expect(buttons).toHaveLength(1) + const tabBar = buttons[0].parent + expect(ts.isJsxElement(tabBar)).toBe(true) + if (!ts.isJsxElement(tabBar)) { + return + } + expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View') + const style = tabBar.openingElement.attributes.properties.find( + (attribute): attribute is ts.JsxAttribute => + ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style' + ) + expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}') + expect(tabSource).toContain('if (quickCommandsSupported === true)') + expect(tabSource).toContain('setShowQuickCommands(true)') + expect(tabSource).toContain('Desktop update required for quick commands') + expect(tabSource).toContain('Checking desktop capabilities — try again in a moment') + }) + + it('only presents the sheet after support is confirmed', () => { + expect(source).toContain('visible={showQuickCommands && quickCommandsSupported === true}') + }) +}) From 17e471c51e9ec8721d4acfbbd9036e6379183c67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:32:34 -0700 Subject: [PATCH 13/29] perf(editor): memoize the markdown preview render pipeline (#9843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-markdown's has no internal memoization: it rebuilds the whole unified remark->rehype->highlight->katex processor and re-parses the document on every render. MarkdownPreview re-renders on internal state that does not affect the rendered output — most visibly, every keystroke in Find (query/match-index state) — so a large doc re-ran the full parse + syntax-highlight + KaTeX pass per keypress, making Find laggy. Hoist the two fully-static plugin arrays to module scope (a fresh array identity per render would defeat the memo) and render the body through a React.memo'd MarkdownBody keyed on content + components. The pipeline now re-runs only when the rendered content or the components map actually changes; Find/review-pulse/copied- note re-renders skip it. The components map was already memoized, so its identity is stable across those re-renders. Behavior unchanged: 106 existing MarkdownPreview tests pass. Co-authored-by: Orca --- .../src/components/editor/MarkdownPreview.tsx | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index f74e48c4c3a..26f791332b2 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: MarkdownPreview keeps rendering, link interception, search, and viewport state together so preview behavior stays coherent. */ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: search match state is synchronized with DOM highlights inserted into the rendered markdown body. */ import React, { + memo, useCallback, useEffect, useLayoutEffect, @@ -30,7 +31,7 @@ import { Plus, X } from 'lucide-react' -import type { Components } from 'react-markdown' +import type { Components, Options as ReactMarkdownOptions } from 'react-markdown' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' @@ -328,6 +329,53 @@ const markdownPreviewSanitizeSchema = { } } +// Why: react-markdown's has no internal memoization — it rebuilds the whole +// unified remark→rehype pipeline and re-parses the document on EVERY render. These plugin +// lists are fully static, so hoist them to module scope; a fresh array identity per render +// would otherwise defeat the memoized body below. +type MarkdownPluginList = NonNullable +const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [ + remarkGfm, + remarkBreaks, + remarkFrontmatter, + remarkMath, + remarkMarkdownDocLinks +] +// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema. +const MARKDOWN_REHYPE_PLUGINS: MarkdownPluginList = [ + rehypeRaw, + [rehypeSanitize, markdownPreviewSanitizeSchema], + rehypeSlug, + rehypeHighlight, + rehypeKatex +] + +// Why: render the body through a memoized wrapper so the expensive pipeline only re-runs when +// the rendered content or the components map changes. Find state (query/match index) and +// body-unrelated toolbar re-renders touch neither prop, so keystrokes in Find no longer +// re-parse+re-highlight the whole doc. (Inline-annotation/review state — attentionReviewCommentId, +// copiedReviewNoteId, activeAnnotationBlockKey — deliberately rebuilds `components`, so those +// re-renders still re-run the pipeline; that's required to update the live annotation markup.) +const MarkdownBody = memo(function MarkdownBody({ + content, + components +}: { + content: string + components: Components +}) { + return ( + + {content} + + ) +}) + function parseLineTarget(hash: string): { line: number; column?: number } | null { if (!hash) { return null @@ -1947,28 +1995,7 @@ export default function MarkdownPreview({ ) : null} - - {renderedContent} - + From 8cfb8a2a2be19b6a6cd35f155c4f2b363d3f4d74 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:33:03 -0700 Subject: [PATCH 14/29] perf(emulator): park device stream when the window is hidden (#9842) The iOS MJPEG and Android scrcpy device streams are gated only on the pane being the active tab (isActive, PR #7382). When the emulator tab is frontmost but the whole Orca window is hidden/minimized/occluded/display-asleep, the full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode + IPC + renderer decode. Renderer background-throttling (#9395) cannot stop it because the pipeline is IPC-push driven from main. Gate showStream additionally on window visibility via a new occlusion-safe hook that honors the terminal stale-visibility latch (so a display-sleep occlusion wedge can't freeze the emulator on a black frame) and delays the visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate the device stream. Co-authored-by: Orca --- .../emulator-device-frame-visibility.test.tsx | 81 +++++++++++++++++++ .../emulator-pane/emulator-device-frame.tsx | 11 ++- .../use-emulator-stream-window-visibility.ts | 59 ++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx index b2a9621bae1..f202aa9dcdd 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -4,6 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EmulatorDeviceFrame } from './emulator-device-frame' +import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility' +import { EMULATOR_STREAM_PARK_DELAY_MS } from './use-emulator-stream-window-visibility' // Why: a backgrounded but still-attached emulator must stop streaming frames. // The perf contract is that no frame stream is started (no per-frame IPC / MJPEG @@ -56,9 +58,20 @@ afterEach(() => { delete (URL as Partial).createObjectURL delete (URL as Partial).revokeObjectURL delete (window as { api?: unknown }).api + setDocumentVisibility('visible') + resetStaleDocumentVisibilityForTesting() + vi.useRealTimers() vi.restoreAllMocks() }) +function setDocumentVisibility(state: 'visible' | 'hidden'): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state + }) + document.dispatchEvent(new Event('visibilitychange')) +} + async function renderFrame(isActive: boolean): Promise { await act(async () => { root.render( @@ -102,3 +115,71 @@ describe('EmulatorDeviceFrame visibility gating', () => { expect(startFrameStream).toHaveBeenCalledTimes(2) }) }) + +describe('EmulatorDeviceFrame window-visibility gating', () => { + it('parks the stream after the window stays hidden past the park delay, and resumes when visible', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Hiding the window does not tear down immediately — a short grace covers a + // quick Cmd+Tab round-trip. + await act(async () => { + setDocumentVisibility('hidden') + }) + expect(stopFrameStream).not.toHaveBeenCalled() + + // Once the grace elapses, the stream parks at the source. + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS) + }) + expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' }) + + // Returning to the window resumes immediately; the session was never detached. + await act(async () => { + setDocumentVisibility('visible') + }) + expect(startFrameStream).toHaveBeenCalledTimes(2) + }) + + it('does not tear down on a quick hide/show within the park delay', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + await act(async () => { + setDocumentVisibility('hidden') + }) + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100) + }) + await act(async () => { + setDocumentVisibility('visible') + }) + // The park timer was cancelled by the return-to-visible, so the stream never + // stopped and no reconnect was needed. + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS) + }) + expect(stopFrameStream).not.toHaveBeenCalled() + expect(startFrameStream).toHaveBeenCalledTimes(1) + }) + + it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Window reports hidden, but real user input proves the occlusion tracker is + // wedged; the stream must keep running instead of freezing on a black frame. + await act(async () => { + setDocumentVisibility('hidden') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) + }) + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2) + }) + expect(stopFrameStream).not.toHaveBeenCalled() + expect(startFrameStream).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx index 9b278241d17..1dd787c9c99 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -31,6 +31,7 @@ import { EmulatorScreenSurface } from './emulator-screen-surface' import { useEmulatorControlStream } from './use-emulator-control-stream' import { useEmulatorPaneSize } from './use-emulator-pane-size' import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard' +import { useEmulatorStreamWindowVisible } from './use-emulator-stream-window-visibility' type EmulatorDeviceFrameProps = { previewUrl?: string @@ -335,9 +336,13 @@ export function EmulatorDeviceFrame({ setStreamError(true) }, []) - // Why: hidden panes still receive emulator frames, including over SSH, so - // parking the stream avoids background decode/IPC churn while staying attached. - const showStream = isActive && isLive && Boolean(previewUrl) + // Why: a hidden/occluded window (or a background tab) still receives emulator + // frames, including over SSH; parking the stream avoids background decode/IPC + // churn while staying attached. isActive covers the background-tab case; + // windowVisibleForStream additionally parks when the whole window is hidden + // (minimize / occlusion / display sleep), which no tab gate catches. + const windowVisibleForStream = useEmulatorStreamWindowVisible() + const showStream = isActive && isLive && windowVisibleForStream && Boolean(previewUrl) const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 // Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the // physical frame still follows the last successful rotate request. diff --git a/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts b/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts new file mode 100644 index 00000000000..3ca818b2d6f --- /dev/null +++ b/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts @@ -0,0 +1,59 @@ +import { useEffect, useState, useSyncExternalStore } from 'react' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { + isDocumentVisibilityProvenStale, + registerStaleDocumentVisibilityRecovery +} from '../terminal-pane/stale-document-visibility' + +// Why: after display sleep macOS can wedge document.visibilityState at 'hidden' +// with no further visibilitychange event; honor the terminal occlusion-staleness +// latch so a window the user is actually looking at is never treated as hidden — +// otherwise the emulator freezes on a black frame with no recovery (same bug class +// as the 78MB terminal drop that motivated the latch). +function getWindowVisibleSnapshot(): boolean { + return isWindowVisible() || isDocumentVisibilityProvenStale() +} + +function subscribeWindowVisible(onChange: () => void): () => void { + const handler = (): void => onChange() + document.addEventListener('visibilitychange', handler) + // Why: the stale latch flips visibility to proven-visible without emitting a + // visibilitychange, so recompute when it fires too. + const unregister = registerStaleDocumentVisibilityRecovery(handler) + return () => { + document.removeEventListener('visibilitychange', handler) + unregister() + } +} + +// Why: parking is delayed so a quick Cmd+Tab / app-switch round-trip does not tear +// down and renegotiate the device stream (MJPEG reconnect or scrcpy H.264 keyframe), +// which is heavier than a terminal resync and flashes the "Connecting…" UI. Re-showing +// restores immediately. +export const EMULATOR_STREAM_PARK_DELAY_MS = 500 + +/** + * Reactive "is this window visible enough to keep the emulator device stream + * running" signal. Returns true while the window is visible (or occlusion state is + * proven stale) and defers the visible→hidden transition by `parkDelayMs`. + */ +export function useEmulatorStreamWindowVisible( + parkDelayMs = EMULATOR_STREAM_PARK_DELAY_MS +): boolean { + const rawVisible = useSyncExternalStore( + subscribeWindowVisible, + getWindowVisibleSnapshot, + getWindowVisibleSnapshot + ) + const [effectiveVisible, setEffectiveVisible] = useState(rawVisible) + useEffect(() => { + if (rawVisible) { + // Restore immediately so returning to the window resumes without delay. + setEffectiveVisible(true) + return + } + const timer = window.setTimeout(() => setEffectiveVisible(false), parkDelayMs) + return () => window.clearTimeout(timer) + }, [rawVisible, parkDelayMs]) + return effectiveVisible +} From b0e36169d8fe882263a28ea2261c6acf55dc5a8b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:37:34 -0700 Subject: [PATCH 15/29] fix: return native promise from awaitClick on Zone.js pages (#9995) Zone.js patches the global Promise with a non-native thenable. When a bare `new Promise(...)` crosses the Electron executeJavaScript boundary, it's serialized as-is, losing { page, target } and exposing __zone_symbol__* fields instead. Wrap in an async IIFE to return a native promise that Electron always unwraps correctly. --- src/main/browser/grab-guest-script.test.ts | 199 +++++++++++++++++++++ src/main/browser/grab-guest-script.ts | 151 ++++++++-------- 2 files changed, 280 insertions(+), 70 deletions(-) diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index 17f9f95b483..a86764a5733 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -1,5 +1,8 @@ +import { types } from 'node:util' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' describe('buildGuestOverlayScript', () => { it('returns a non-empty string for arm action', () => { @@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => { expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)') }) }) + +// Regression coverage for issue #9947: Zone.js swaps the global Promise for a +// non-native thenable, which executeJavaScript won't unwrap — so a page-global +// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }. +describe('awaitClick under a Zone.js-patched global Promise', () => { + type Executor = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void + + // Native promises are kept off the instance so its own enumerable keys match + // Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value']. + const nativeOf = new WeakMap>() + + /** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */ + class ZoneAwarePromiseLike { + __zone_symbol__state: unknown = null + __zone_symbol__value: unknown = undefined + + constructor(executor: Executor) { + nativeOf.set( + this, + new Promise((res, rej) => { + executor( + (value) => { + this.__zone_symbol__state = true + this.__zone_symbol__value = value + res(value) + }, + (reason) => { + this.__zone_symbol__state = false + this.__zone_symbol__value = reason + rej(reason) + } + ) + }) + ) + } + + // Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`, + // so an Object.prototype.toString check is fooled into seeing a promise. The + // boundary below deliberately uses the brand-based V8 check instead, and the + // control test asserts this tag does NOT let the wrapper masquerade as native. + get [Symbol.toStringTag](): string { + return 'Promise' + } + + // oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise + then( + onFulfilled?: ((value: unknown) => unknown) | null, + onRejected?: ((reason: unknown) => unknown) | null + ): Promise { + const native = nativeOf.get(this) as Promise + return native.then(onFulfilled ?? undefined, onRejected ?? undefined) + } + } + + /** + * Models Electron's boundary: it awaits only genuine V8 promises and + * serializes anything else — including non-native thenables — by value. + * `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses): + * realm-independent and unforgeable, unlike an Object.prototype.toString / + * Symbol.toStringTag check that a ZoneAwarePromise would defeat. + */ + async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise { + if (types.isPromise(completionValue)) { + return await (completionValue as Promise) + } + return { ...(completionValue as Record) } + } + + const validPayload = (): Record => ({ + // A minimal but structurally valid payload — page + target are what + // clampGrabPayload requires and what the bug stripped away. + page: { title: 'Angular App' }, + target: { tagName: 'button' }, + nearbyText: [], + ancestorPath: [], + screenshot: null + }) + + function armGrabHarness(options?: { + extractPayload?: () => unknown + getCurrentElement?: () => unknown + }): { + window: { __orcaGrab: Record } + click: () => void + contextmenu: () => void + cancel: () => void + } { + const handlers: Record void> = {} + const noopEvent = { + preventDefault(): void {}, + stopPropagation(): void {}, + stopImmediatePropagation(): void {} + } + const grab: Record = { + host: { + addEventListener(type: string, fn: (event: unknown) => void): void { + handlers[type] = fn + }, + removeEventListener(): void {} + }, + extractPayload: options?.extractPayload ?? validPayload, + getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})), + freezeHighlight(): void {}, + cleanup(): void {} + } + const window = { __orcaGrab: grab } + return { + window, + click: () => handlers.click?.(noopEvent), + contextmenu: () => handlers.contextmenu?.(noopEvent), + // cancelAwait is installed on __orcaGrab by the script itself at runtime. + cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.() + } + } + + const runAwaitClick = (harness: ReturnType): unknown => + runInNewContext(buildGuestOverlayScript('awaitClick'), { + window: harness.window, + Promise: ZoneAwarePromiseLike, + Error + }) + + it('returns the payload through a native async promise, not the page-global Promise', () => { + const script = buildGuestOverlayScript('awaitClick') + expect(script).toContain('(async function()') + expect(script).toContain('return await new Promise(') + }) + + it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + // The async IIFE hands Electron an intrinsic promise even though the global + // Promise is a non-native thenable — so the boundary unwraps it. + expect(types.isPromise(completion)).toBe(true) + + harness.click() + const received = await crossExecuteJavaScriptBoundary(completion) + + expect(received).toHaveProperty('page') + expect(received).toHaveProperty('target') + expect(received).not.toHaveProperty('__zone_symbol__value') + expect(clampGrabPayload(received)).not.toBeNull() + }) + + it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + harness.contextmenu() + const received = (await crossExecuteJavaScriptBoundary(completion)) as Record + + expect(received).toHaveProperty('__orcaContextMenu', true) + expect(received.payload).toHaveProperty('page') + expect(clampGrabPayload(received.payload)).not.toBeNull() + }) + + it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + harness.cancel() + const received = await crossExecuteJavaScriptBoundary(completion) + + expect(received).toEqual({ __orcaCancelled: true }) + }) + + it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => { + // getCurrentElement -> null drives onClick's reject(new Error('cancelled')), + // which must surface as a rejected intrinsic promise (not a serialized value) + // so the controller classifies it as a cancellation rather than a payload. + const harness = armGrabHarness({ getCurrentElement: () => null }) + const completion = runAwaitClick(harness) + + harness.click() + await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled') + }) + + it('control: a bare page-global new Promise would cross as the raw wrapper', async () => { + // Proves the harness detects the regression: without the async wrapper the + // completion value is the ZoneAwarePromise itself, which the boundary + // serializes to __zone_symbol__* fields with no page/target. + const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', { + Promise: ZoneAwarePromiseLike + }) + // Symbol.toStringTag='Promise' fools a toString check — exactly why the + // boundary must use the brand-based IsPromise, which still rejects it. + expect(Object.prototype.toString.call(bare)).toBe('[object Promise]') + expect(types.isPromise(bare)).toBe(false) + + const received = await crossExecuteJavaScriptBoundary(bare) + expect(received).not.toHaveProperty('page') + expect(received).toHaveProperty('__zone_symbol__value') + expect(clampGrabPayload(received)).toBeNull() + }) +}) diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index 8841fb6cf97..d1d089be1af 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() { })()` // awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page. -const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { - 'use strict'; - var grab = window.__orcaGrab; - if (!grab) { - reject(new Error('Grab not armed')); - return; - } - - function extractSelectedPayload(el) { - try { - return grab.extractPayload(el); - } catch (error) { - grab.cleanup(); - reject(error instanceof Error ? error : new Error('Failed to extract element context')); - return null; - } - } - - function onClick(e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - var el = grab.getCurrentElement(); - if (!el) { - grab.cleanup(); - reject(new Error('cancelled')); +const AWAIT_CLICK_SCRIPT = `(async function() { + // Why: hand the click result to executeJavaScript through a native (intrinsic) + // Promise. On pages that replace the global Promise with a non-native thenable + // — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not + // recognized as a promise by Electron, so its raw wrapper object (exposing + // __zone_symbol__state/__value instead of { page, target }) crosses the boundary + // and main rejects it as an invalid payload structure. An async function's + // promise comes from the engine intrinsic that page code cannot reassign, so + // Electron always unwraps it to the resolved payload. + return await new Promise(function(resolve, reject) { + 'use strict'; + var grab = window.__orcaGrab; + if (!grab) { + reject(new Error('Grab not armed')); return; } - var payload = extractSelectedPayload(el); - if (!payload) return; - // Why: freeze the highlight instead of removing it so the user sees - // which element was selected while the copy menu is shown. Teardown - // happens later when the renderer calls setGrabMode(false) or re-arms. - grab.freezeHighlight(); - resolve(payload); - } - function onContext(e) { - // Why: right-click resolves with the payload wrapped in a context-menu - // marker so the renderer can show the full action dropdown instead of - // auto-copying. This gives users a deliberate path to screenshot and - // other secondary actions while keeping left-click as the fast copy path. - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - var el = grab.getCurrentElement(); - if (!el) { - grab.cleanup(); - reject(new Error('cancelled')); - return; + function extractSelectedPayload(el) { + try { + return grab.extractPayload(el); + } catch (error) { + grab.cleanup(); + reject(error instanceof Error ? error : new Error('Failed to extract element context')); + return null; + } } - var payload = extractSelectedPayload(el); - if (!payload) return; - grab.freezeHighlight(); - resolve({ __orcaContextMenu: true, payload: payload }); - } - grab.host.addEventListener('click', onClick, true); - grab.host.addEventListener('contextmenu', onContext, true); + function onClick(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + var el = grab.getCurrentElement(); + if (!el) { + grab.cleanup(); + reject(new Error('cancelled')); + return; + } + var payload = extractSelectedPayload(el); + if (!payload) return; + // Why: freeze the highlight instead of removing it so the user sees + // which element was selected while the copy menu is shown. Teardown + // happens later when the renderer calls setGrabMode(false) or re-arms. + grab.freezeHighlight(); + resolve(payload); + } - // Store cancel hook so teardown can settle the Promise - grab.cancelAwait = function() { - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - grab.cleanup(); - // Why: teardown cancellation is a normal user flow; resolving a marker - // avoids a noisy guest-console Error while main still treats it as cancel. - resolve({ __orcaCancelled: true }); - }; -})` + function onContext(e) { + // Why: right-click resolves with the payload wrapped in a context-menu + // marker so the renderer can show the full action dropdown instead of + // auto-copying. This gives users a deliberate path to screenshot and + // other secondary actions while keeping left-click as the fast copy path. + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + var el = grab.getCurrentElement(); + if (!el) { + grab.cleanup(); + reject(new Error('cancelled')); + return; + } + var payload = extractSelectedPayload(el); + if (!payload) return; + grab.freezeHighlight(); + resolve({ __orcaContextMenu: true, payload: payload }); + } + + grab.host.addEventListener('click', onClick, true); + grab.host.addEventListener('contextmenu', onContext, true); + + // Store cancel hook so teardown can settle the Promise + grab.cancelAwait = function() { + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + grab.cleanup(); + // Why: teardown cancellation is a normal user flow; resolving a marker + // avoids a noisy guest-console Error while main still treats it as cancel. + resolve({ __orcaCancelled: true }); + }; + }); +})()` const FINALIZE_SCRIPT = `(function() { 'use strict'; @@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() { 'use strict'; var grab = window.__orcaGrab; if (!grab) return true; - // If there's an active awaitClick Promise, cancel it so the - // executeJavaScript call in main rejects and settles the grab op. + // If there's an active awaitClick Promise, cancel it: cancelAwait resolves + // it with the __orcaCancelled marker so the executeJavaScript call in main + // settles the grab op as a cancellation. if (grab.cancelAwait) { grab.cancelAwait(); } else { From 4062ed5a73865cb6d3aeb2ac04147633a1991aa8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:42:58 -0700 Subject: [PATCH 16/29] perf(worktrees): back off worktree scans for agent-scratch repos (#9985) Production crash diagnostics measured ~128 `git worktree list` execs/min (9,400 in one 80-minute session, ~16% of wall-clock in git subprocesses): the resolved-worktree scan fans out over every registered repo on a 30s cache TTL, and most registered repos on the affected installs were agent-CLI scratch repos (~/.codex-tmp capsules, vendor imports, skill checkouts) that need no freshness. Classify agent-scratch repo roots with a curated shared matcher and stamp their scan-cache entries with a 5-minute TTL instead of 30s. Orca-driven mutations still bypass the TTL via the per-repo generation bump, so only passive pickup of external changes slows for scratch repos. Expected steady-state reduction on the measured install: ~82% fewer git spawns. --- src/main/runtime/orca-runtime.test.ts | 81 ++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 13 +++- src/shared/agent-scratch-worktrees.test.ts | 42 +++++++++++ src/shared/agent-scratch-worktrees.ts | 24 +++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f58d0610f57..3a5099edde2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,6 +56,7 @@ import { OrcaRuntimeService, recentTerminalPathCandidatesIncludePath, recentTerminalOutputIncludesPath, + resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' @@ -34979,3 +34980,83 @@ describe('OrcaRuntimeService', () => { }) }) }) + +describe('resolveWorktreeScanCacheTtlMs', () => { + const BASE_TTL_MS = 30_000 + const SCRATCH_TTL_MS = 5 * 60_000 + + it('keeps the base TTL for ordinary local repos', () => { + expect( + resolveWorktreeScanCacheTtlMs({ path: '/Users/dev/projects/app', connectionId: '' }) + ).toBe(BASE_TTL_MS) + }) + + it('extends the TTL for agent-scratch repo roots', () => { + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6', + connectionId: '' + }) + ).toBe(SCRATCH_TTL_MS) + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/Users/dev/.claude/skills/obsidian-second-brain', + connectionId: '' + }) + ).toBe(SCRATCH_TTL_MS) + }) + + it('never extends the TTL for SSH repos', () => { + // Why: scratch classification reads local path conventions; a remote path + // that merely looks similar must keep normal freshness. + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/home/dev/.codex-tmp/capsule', + connectionId: 'ssh-1' + }) + ).toBe(BASE_TTL_MS) + }) + + it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => { + // Why: the whole fix lives in the cache-stamp call site; pin the wiring so + // a revert to the flat TTL fails CI, not just the pure-function tests. + vi.useFakeTimers() + // Why: the shared listWorktrees stub keeps call history across this file's + // tests; absolute counts need a clean baseline. + vi.mocked(listWorktrees).mockClear() + try { + const scratchPath = '/tmp/.codex-tmp/capsule-a' + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [ + { id: 'repo-1', path: '/tmp/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }, + { + id: 'repo-scratch', + path: scratchPath, + displayName: 'capsule', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as never) + const internals = runtime as unknown as { listResolvedWorktrees: () => Promise } + const scanCallsFor = (path: string): number => + vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length + + await internals.listResolvedWorktrees() + expect(scanCallsFor('/tmp/repo')).toBe(1) + expect(scanCallsFor(scratchPath)).toBe(1) + + vi.advanceTimersByTime(BASE_TTL_MS + 1_000) + await internals.listResolvedWorktrees() + expect(scanCallsFor('/tmp/repo')).toBe(2) + expect(scanCallsFor(scratchPath)).toBe(1) + + vi.advanceTimersByTime(SCRATCH_TTL_MS) + await internals.listResolvedWorktrees() + expect(scanCallsFor(scratchPath)).toBe(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5c25ce81792..bbeba293b58 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -314,6 +314,7 @@ import { } from '../../shared/worktree-ownership' import { createAgentScratchWorktreePathMatcher, + isAgentScratchRepoRootPath, type AgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees' import { @@ -23853,7 +23854,7 @@ export class OrcaRuntimeService { generation, runtimeKey, result, - expiresAt: Date.now() + WORKTREE_SCAN_CACHE_TTL_MS + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo) }) } return result @@ -29253,7 +29254,17 @@ const DEFAULT_WORKTREE_PS_LIMIT = 200 const DISCONNECTED_PTY_RECORD_MAX = 128 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 +// Why: agent-scratch repos don't need 30s freshness — the steady-state scan +// fan-out was measured at ~128 git execs/min on real installs, mostly against +// these (crash-cluster diagnostics, 2026-07). +const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 + +export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { + return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) + ? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS + : WORKTREE_SCAN_CACHE_TTL_MS +} const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 // Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out. const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000 diff --git a/src/shared/agent-scratch-worktrees.test.ts b/src/shared/agent-scratch-worktrees.test.ts index b2714bd6464..839833c004e 100644 --- a/src/shared/agent-scratch-worktrees.test.ts +++ b/src/shared/agent-scratch-worktrees.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createAgentScratchWorktreePathMatcher, + isAgentScratchRepoRootPath, isAgentScratchWorktreePath } from './agent-scratch-worktrees' @@ -101,3 +102,44 @@ describe('isAgentScratchWorktreePath', () => { expect(isAgentScratchWorktreePath('/Users/dev/app', '/orca/workspaces/app/feature')).toBe(false) }) }) + +describe('isAgentScratchRepoRootPath', () => { + it('matches codex scratch capsule repos', () => { + expect( + isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6') + ).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/rc-fwd-qEXuEq')).toBe(true) + }) + + it('matches codex vendor imports and claude skills containers', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports/skills')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.claude/skills/obsidian-second-brain')).toBe(true) + }) + + it('matches a repo registered at the scratch container itself', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports')).toBe(true) + }) + + it('matches scratch worktree containers used as repo roots', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/app/.claude/worktrees/agent-a04ccaaa')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/app/.gsd-workspaces/phase-1')).toBe(true) + }) + + it('matches Windows separators and casing', () => { + expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.codex-tmp\\Capsule-X')).toBe(true) + expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.Claude\\Skills\\foo')).toBe(true) + }) + + it('does not match ordinary user repos', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/projects/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/codex-tmp/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/checkouts/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/skills/.claude-app')).toBe(false) + }) + + it('does not match partial multi-segment markers', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.claude/config')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/vendor_imports/app')).toBe(false) + }) +}) diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index 2ff216fbd6e..88be8646435 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -37,3 +37,27 @@ export function createAgentScratchWorktreePathMatcher( export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean { return createAgentScratchWorktreePathMatcher([repoPath])(worktreePath) } + +/** Why: agent CLIs also mint whole scratch *repos* under these containers; a + * repo registered at such a root is agent-internal, not a user project (#9388). */ +const AGENT_SCRATCH_REPO_ROOT_SEGMENTS: readonly (readonly string[])[] = [ + ['.codex-tmp'], + ['.codex', 'vendor_imports'], + ['.claude', 'skills'], + ...AGENT_SCRATCH_PATH_PREFIXES +] + +export function isAgentScratchRepoRootPath(repoPath: string): boolean { + const segments = normalizeRuntimePathForComparison(repoPath).split('/') + for (const marker of AGENT_SCRATCH_REPO_ROOT_SEGMENTS) { + // Why: match the marker anywhere above the repo root (the repo lives at or + // under the scratch container), unlike worktree matching which anchors to a + // registered checkout path. + for (let index = 0; index + marker.length <= segments.length; index += 1) { + if (marker.every((segment, offset) => segments[index + offset] === segment)) { + return true + } + } + } + return false +} From 8dc1ca49200377531caee20cbe2dbb5e3c344c41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:45:03 -0700 Subject: [PATCH 17/29] fix(daemon): retire macOS daemons whose login session died (#7936) (#9826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): retire macOS daemons whose login session died (#7936) A daemon that survives a full macOS logout is unsalvageable: its PAM context can no longer host login(1) spawns (every new PTY becomes a 'Login incorrect' prompt zombie) and its Mach bootstrap namespace has lost the system DNS resolver, so terminals it hosts have no egress. Today it also keeps the #9301 preflight's cached 'accepted' verdict, so it keeps wrapping spawns in login(1) forever; only a manual daemon restart recovers. GUI-spawned daemons now watch for login-session death from the inside: a fresh cache-bypassing PAM probe (triggered by PTY-exit bursts, fresh client hellos, and a slow periodic timer) must conclusively reject three consecutive times AND the in-process system resolver must be degraded; then the daemon exits crash-style so session meta stays unclean and the replacement daemon cold-restores scrollback. A conclusive rejection also flips the spawn-wrapper cache off immediately. Headless serve/SSH daemons never get the watch (they must survive their spawning session ending), and a session that never conclusively accepted login(1) never arms it — a PAM anomaly alone can't kill a healthy daemon (fast user switching keeps accepting, so switched-away sessions are preserved). * test(daemon): e2e seam to drive login-session death oracles from a verdict file A dead macOS login session cannot be fabricated without root (PAM owns audit-session teardown), so live lifecycle QA drives the death watch's probe and resolver oracles from ORCA_E2E_LOGIN_SESSION_PROBE_FILE: 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, anything else inconclusive — with compressed watch timing. Mirrors the existing ORCA_E2E_DAEMON_INIT_DELAY_MS seam; inert unless the env var is set. * fix(daemon): close the hang-shaped gap in login-session death detection The conclusive-PAM-verdict trigger had one blind failure shape: login(1) hanging at the prompt past the probe bound (killed → inconclusive forever → the watch never fires). Three changes close it: - The death-watch probe gets its own 4s bound (the 500ms preflight bound exists for spawn-path latency, which doesn't apply off-path), so a slow-but-answering PAM stack isn't misread as a hang. - An inconclusive pipe probe escalates to a PTY-hosted probe via script(1) — a dead session's PAM stack may only misbehave under a real tty (the pipe-vs-PTY fidelity limit the preflight documents). - A streak of timeout-killed probes (which a live session never produces) is a second retirement trigger, at a higher threshold (5) and still gated on the degraded resolver, logged with a distinct cause so field logs discriminate the two paths. Every dead-session behavior — fast reject, prompt-then-EOF, or hang — now fires retirement; all inconclusive states still fail toward preserving the daemon. * fix(daemon): keep login-session retirement conclusive * fix(daemon): stop login watch before clean shutdown * fix(daemon): make login-session PTY probe reliable * fix(daemon): close login-session watch races * fix(daemon): ignore health probes for login watch activity --- ...emon-authenticated-client-activity.test.ts | 77 ++++ src/main/daemon/daemon-entry.test.ts | 13 + src/main/daemon/daemon-entry.ts | 83 +++- src/main/daemon/daemon-init.test.ts | 6 +- src/main/daemon/daemon-init.ts | 13 +- src/main/daemon/daemon-main.ts | 6 + src/main/daemon/daemon-server.ts | 12 +- .../macos-login-session-death-watch.test.ts | 374 ++++++++++++++++++ .../daemon/macos-login-session-death-watch.ts | 317 +++++++++++++++ src/main/daemon/terminal-host-options.ts | 2 + src/main/daemon/terminal-host.test.ts | 3 + src/main/daemon/terminal-host.ts | 3 + src/main/index.ts | 6 +- .../macos-system-resolver-health.test.ts | 17 + .../network/macos-system-resolver-health.ts | 12 +- .../macos-login-session-pty-probe.test.ts | 129 ++++++ .../macos-login-session-pty-probe.ts | 78 ++++ .../providers/macos-tcc-login-shell.test.ts | 221 ++++++++++- src/main/providers/macos-tcc-login-shell.ts | 111 ++++-- 19 files changed, 1440 insertions(+), 43 deletions(-) create mode 100644 src/main/daemon/daemon-authenticated-client-activity.test.ts create mode 100644 src/main/daemon/macos-login-session-death-watch.test.ts create mode 100644 src/main/daemon/macos-login-session-death-watch.ts create mode 100644 src/main/providers/macos-login-session-pty-probe.test.ts create mode 100644 src/main/providers/macos-login-session-pty-probe.ts diff --git a/src/main/daemon/daemon-authenticated-client-activity.test.ts b/src/main/daemon/daemon-authenticated-client-activity.test.ts new file mode 100644 index 00000000000..7da5e00e387 --- /dev/null +++ b/src/main/daemon/daemon-authenticated-client-activity.test.ts @@ -0,0 +1,77 @@ +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { connect, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import { encodeNdjson } from './ndjson' +import type { SubprocessHandle } from './session' +import { PROTOCOL_VERSION } from './types' + +function unusedSubprocess(): SubprocessHandle { + throw new Error('Test must not create a PTY') +} + +describe('daemon authenticated client activity', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + async function connectHello(role: 'control' | 'stream', clientId: string): Promise { + const socket = connect(socketPath) + await new Promise((resolve) => socket.once('connect', resolve)) + socket.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId, + role + }) + ) + await new Promise((resolve, reject) => { + socket.once('data', (data) => { + const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string } + if (response.ok) { + resolve() + } else { + reject(new Error(response.error ?? 'hello rejected')) + } + }) + socket.once('error', reject) + }) + return socket + } + + it('excludes control-only health probes and reports one complete app pair', async () => { + const onAuthenticatedClientPair = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: unusedSubprocess, + onAuthenticatedClientPair + }) + await server.start() + + await connectHello('control', 'resolver-health-check') + expect(onAuthenticatedClientPair).not.toHaveBeenCalled() + + await connectHello('control', 'app-client') + expect(onAuthenticatedClientPair).not.toHaveBeenCalled() + await connectHello('stream', 'app-client') + expect(onAuthenticatedClientPair).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index deedb82a2c8..6b02210d72a 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => { it('still requires --socket and --token when --log-file is given', () => { expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') }) + + it('parses the GUI-only --login-session-watch flag and omits it when absent', () => { + expect( + parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch']) + ).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + loginSessionWatch: true + }) + expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty( + 'loginSessionWatch' + ) + }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 6488ffce048..ba98af21c5b 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -6,19 +6,27 @@ * Signals readiness to parent via IPC: { type: 'ready' } * Shuts down cleanly on SIGTERM. */ +import { readFileSync } from 'node:fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' import { PROTOCOL_VERSION } from './types' -import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell' +import { + prepareMacosTccLoginShell, + probeMacosLoginSessionAlive +} from '../providers/macos-tcc-login-shell' +import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' export type ParsedDaemonArgs = { socketPath: string tokenPath: string pidPath?: string launchNonce?: string + /** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */ + loginSessionWatch?: boolean /** Optional — absent for adopted old daemons and tests, which log nothing. */ logFilePath?: string } @@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let logFilePath = '' let pidPath = '' let launchNonce = '' + let loginSessionWatch = false for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { launchNonce = argv[i + 1] i++ + } else if (argv[i] === '--login-session-watch') { + loginSessionWatch = true } } @@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { socketPath, tokenPath, ...(pidPath ? { pidPath, launchNonce } : {}), + ...(loginSessionWatch ? { loginSessionWatch } : {}), ...(logFilePath ? { logFilePath } : {}) } } @@ -73,7 +85,7 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs( + const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs( process.argv.slice(2) ) const startedAtMs = Date.now() - process.uptime() * 1000 @@ -123,6 +135,7 @@ async function main(): Promise { }) let daemon: DaemonHandle | null = null + let deathWatch: MacosLoginSessionDeathWatch | null = null let shuttingDown = false // Bound the wait so a wedged native shutdown can't leave the daemon running // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). @@ -134,6 +147,7 @@ async function main(): Promise { return } shuttingDown = true + deathWatch?.stop() daemonLog.log('shutdown', { reason }) try { if (daemon) { @@ -157,6 +171,63 @@ async function main(): Promise { process.on('SIGTERM', () => void shutdown('SIGTERM')) process.on('SIGINT', () => void shutdown('SIGINT')) + // Why: a dead macOS login session cannot be fabricated without root (PAM owns + // audit-session teardown), so e2e drives the oracles from a verdict file: + // 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' → + // timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive. + const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE + const readE2eVerdict = (): string => { + try { + return readFileSync(e2eProbeFile as string, 'utf8').trim() + } catch { + return '' + } + } + deathWatch = + loginSessionWatch && process.platform === 'darwin' + ? new MacosLoginSessionDeathWatch({ + probeLoginSession: e2eProbeFile + ? async () => { + const verdict = readE2eVerdict() + if (verdict === 'alive') { + return { ok: true, conclusive: true, reason: 'accepted' } + } + if (verdict === 'dead') { + return { ok: false, conclusive: true, reason: 'rejected' } + } + return { ok: false, conclusive: false, reason: 'timeout' } + } + : probeMacosLoginSessionAlive, + readResolverHealth: e2eProbeFile + ? async () => { + const verdict = readE2eVerdict() + return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy' + } + : readCurrentProcessMacSystemResolverHealth, + ...(e2eProbeFile + ? { + timing: { + periodicProbeMs: 2_000, + rejectionRecheckMs: 500, + ptyExitDebounceMs: 200, + clientActivityMinGapMs: 1_000, + minProbeGapMs: 100 + } + } + : {}), + log: daemonLog, + onRetire: (details) => { + shuttingDown = true + daemonLog.log('login-session-dead-retire', details) + daemonLog.close() + // Why: crash-style exit (no PTY teardown) keeps session meta unclean so the + // replacement daemon cold-restores scrollback; stale socket/pid files ride + // the existing dead-endpoint recovery. + process.exit(1) + } + }) + : null + daemon = await startDaemon({ socketPath, tokenPath, @@ -165,14 +236,22 @@ async function main(): Promise { ...(pidPath ? { startedAtMs } : {}), log: daemonLog, preparePtySpawn: runMacosLoginPreflight, + ...(deathWatch + ? { + onPtySessionExit: () => deathWatch.notifyPtyExit(), + onAuthenticatedClientPair: () => deathWatch.notifyClientActivity() + } + : {}), spawnSubprocess: (opts) => createPtySubprocess(opts), onIdleShutdown: () => { + deathWatch?.stop() shuttingDown = true daemonLog.log('shutdown', { reason: 'idle' }) daemonLog.close() process.exit(0) } }) + deathWatch?.start() // Signal readiness to parent via IPC (if available) if (process.send) { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index c1f72dbc63f..5a3003f767d 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() - await mod.initDaemonPtyProvider() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, @@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/socket', '--token', '/fake/token', + '--login-session-watch', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), @@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') + const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[] + expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch') + expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 32e3baf9365..a748ea10a8b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions( return true } -function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { +function createOutOfProcessLauncher( + runtimeDir: string, + macosLoginSessionWatch = false +): DaemonLauncher { return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => { const entryPath = getDaemonEntryPath() const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir) @@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { pidPath, '--launch-nonce', launchNonce, + ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...daemonLogArgs() ], { @@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } } -export async function initDaemonPtyProvider(signal?: AbortSignal): Promise { +export async function initDaemonPtyProvider( + signal?: AbortSignal, + options: { macosLoginSessionWatch?: boolean } = {} +): Promise { logDaemonMilestone('daemon-init-start') // Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout. const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS) @@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise const newSpawner = new DaemonSpawner({ runtimeDir, - launcher: createOutOfProcessLauncher(runtimeDir) + launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false) }) // Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner. diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 9884cdf0c80..3976c6c21e6 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -11,6 +11,8 @@ export type DaemonStartOptions = { protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] preparePtySpawn?: DaemonServerOptions['preparePtySpawn'] + onPtySessionExit?: DaemonServerOptions['onPtySessionExit'] + onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog onIdleShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] @@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise preparePtySpawn?: () => Promise + // Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections. + onPtySessionExit?: (sessionId: string) => void + onAuthenticatedClientPair?: () => void log?: DaemonFileLog spawnSubprocess: (opts: { sessionId: string @@ -101,6 +104,7 @@ export class DaemonServer { private startedAtMs: number | null private protocolVersion: number private onIdleShutdown: () => void + private onAuthenticatedClientPair: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise private log: DaemonFileLog @@ -180,7 +184,11 @@ export class DaemonServer { now: () => Date.now() } this.token = randomUUID() - this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) + this.host = new TerminalHost({ + spawnSubprocess: opts.spawnSubprocess, + ...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {}) + }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ @@ -481,6 +489,8 @@ export class DaemonServer { } this.setupStreamSocket(socket, client) client.authenticatedPairEstablished = true + // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. + this.onAuthenticatedClientPair() // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement. this.initialAdoptionDeadlineMs = null this.retirementRequested = false diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts new file mode 100644 index 00000000000..1531a33ab20 --- /dev/null +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it, vi } from 'vitest' +import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' +import { createNoopDaemonFileLog } from './daemon-file-log' +import { + MacosLoginSessionDeathWatch, + type MacosLoginSessionDeathWatchOptions +} from './macos-login-session-death-watch' +import type { SystemResolverHealth } from './types' + +const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' } +const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' } +const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' } + +type FakeTimer = { at: number; callback: () => void; cleared: boolean } + +class FakeClock { + private timers: FakeTimer[] = [] + private nowMs = 0 + + setTimeout = (callback: () => void, delayMs: number): unknown => { + const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false } + this.timers.push(timer) + return timer + } + + clearTimeout = (handle: unknown): void => { + ;(handle as FakeTimer).cleared = true + } + + now = (): number => this.nowMs + + async advance(ms: number): Promise { + const target = this.nowMs + ms + for (;;) { + const due = this.timers + .filter((t) => !t.cleared && t.at <= target) + .sort((a, b) => a.at - b.at)[0] + if (!due) { + break + } + this.nowMs = due.at + due.cleared = true + due.callback() + // Why: probes are async; let their promise chains settle before firing the next timer. + await drainMicrotasks() + } + this.nowMs = target + } + + pendingCount(): number { + return this.timers.filter((t) => !t.cleared).length + } +} + +async function drainMicrotasks(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +function createWatch( + overrides: Partial & { + outcomes?: (LoginPreflightOutcome | null)[] + } = {} +): { + watch: MacosLoginSessionDeathWatch + clock: FakeClock + onRetire: ReturnType + probe: ReturnType + setResolverHealth: (health: SystemResolverHealth) => void +} { + const clock = new FakeClock() + const onRetire = vi.fn() + const outcomes = overrides.outcomes ?? [] + // Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch. + const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED)) + let resolverHealth: SystemResolverHealth = 'unhealthy' + const watch = new MacosLoginSessionDeathWatch({ + probeLoginSession: overrides.probeLoginSession ?? probe, + readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth), + onRetire: overrides.onRetire ?? onRetire, + log: overrides.log ?? createNoopDaemonFileLog(), + clock, + timing: { + periodicProbeMs: 120_000, + rejectionRecheckMs: 10_000, + ptyExitDebounceMs: 2_000, + clientActivityMinGapMs: 30_000, + minProbeGapMs: 5_000, + ...overrides.timing + } + }) + return { + watch, + clock, + onRetire, + probe, + setResolverHealth: (health) => { + resolverHealth = health + } + } +} + +describe('MacosLoginSessionDeathWatch', () => { + it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(120_000) // periodic → rejection 1 + await clock.advance(10_000) // recheck → rejection 2 + expect(onRetire).not.toHaveBeenCalled() + await clock.advance(10_000) // recheck → rejection 3 → retire + expect(onRetire).toHaveBeenCalledWith({ + cause: 'pam-rejections', + rejections: 3, + resolverHealth: 'unhealthy' + }) + }) + + it('never retires when the session was never conclusively accepted', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + for (let i = 0; i < 4; i++) { + await clock.advance(120_000) + } + expect(onRetire).not.toHaveBeenCalled() + }) + + it('resets the rejection streak on a conclusive acceptance', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // rejection 2 + await clock.advance(10_000) // acceptance → reset + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // rejection 2 + expect(onRetire).not.toHaveBeenCalled() + }) + + it('keeps the rejection streak across interleaved inconclusive probes', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED] + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // inconclusive timeout — rejection streak holds + await clock.advance(120_000) // rejection 2 + await clock.advance(10_000) // inconclusive + await clock.advance(120_000) // rejection 3 → retire + expect(onRetire).toHaveBeenCalledTimes(1) + expect(onRetire.mock.calls[0][0].rejections).toBe(3) + expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections') + }) + + it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + for (let i = 0; i < 10; i++) { + await clock.advance(120_000) + } + expect(probe).toHaveBeenCalledTimes(11) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + }) + + it.each(['healthy', 'unknown'] as const)( + 'suppresses retirement while resolver health is %s, then retires on explicit degradation', + async (initialResolverHealth) => { + const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] + }) + setResolverHealth(initialResolverHealth) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) // threshold reached but resolver did not corroborate death + expect(onRetire).not.toHaveBeenCalled() + const probesAtSuppression = probe.mock.calls.length + setResolverHealth('unhealthy') + await clock.advance(10_000) + expect(probe).toHaveBeenCalledTimes(probesAtSuppression) + await clock.advance(110_000) // suppressed states return to the bounded periodic cadence + expect(onRetire).toHaveBeenCalledTimes(1) + } + ) + + it('debounces a sustained PTY-exit burst into one trailing probe', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] }) + watch.start() + await drainMicrotasks() + await clock.advance(60_000) + const before = probe.mock.calls.length + watch.notifyPtyExit() + await clock.advance(1_500) + watch.notifyPtyExit() + await clock.advance(1_500) + watch.notifyPtyExit() + await clock.advance(1_999) + expect(probe).toHaveBeenCalledTimes(before) + await clock.advance(1) + expect(probe.mock.calls.length).toBe(before + 1) + }) + + it('probes on client activity only after the min gap', async () => { + const { watch, clock, probe } = createWatch({ + outcomes: [ACCEPTED, ACCEPTED, ACCEPTED] + }) + watch.start() + await drainMicrotasks() + const after = probe.mock.calls.length + watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe + await clock.advance(29_999) + expect(probe.mock.calls.length).toBe(after) + await clock.advance(1) + expect(probe.mock.calls.length).toBe(after + 1) + }) + + it('defers a PTY-exit trigger that lands inside the global probe gap', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] }) + watch.start() + await drainMicrotasks() + + watch.notifyPtyExit() + await clock.advance(4_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains one follow-up when a logout signal arrives during a probe', async () => { + let resolveStartup!: (outcome: LoginPreflightOutcome) => void + const startup = new Promise((resolve) => { + resolveStartup = resolve + }) + const probe = vi + .fn() + .mockReturnValueOnce(startup) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + watch.notifyPtyExit() + await clock.advance(2_000) + expect(probe).toHaveBeenCalledOnce() + + resolveStartup(ACCEPTED) + await drainMicrotasks() + await clock.advance(2_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains client activity that arrives during an armed periodic probe', async () => { + let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void + const periodic = new Promise((resolve) => { + resolvePeriodic = resolve + }) + const probe = vi + .fn() + .mockResolvedValueOnce(ACCEPTED) + .mockReturnValueOnce(periodic) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + + watch.notifyClientActivity() + resolvePeriodic(ACCEPTED) + await drainMicrotasks() + await clock.advance(29_999) + expect(probe).toHaveBeenCalledTimes(2) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(3) + }) + + it('disables itself when the wrapper machinery does not apply', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [null] }) + watch.start() + await drainMicrotasks() + expect(probe).toHaveBeenCalledTimes(1) + await clock.advance(600_000) + watch.notifyPtyExit() + watch.notifyClientActivity() + await clock.advance(600_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('stop() cancels all pending timers', async () => { + const { watch, clock } = createWatch({ outcomes: [ACCEPTED] }) + watch.start() + await drainMicrotasks() + watch.notifyPtyExit() + watch.stop() + expect(clock.pendingCount()).toBe(0) + }) + + it('stop() aborts an in-flight subprocess probe', async () => { + let probeSignal: AbortSignal | undefined + const probe = vi.fn((signal?: AbortSignal) => { + probeSignal = signal + return new Promise(() => {}) + }) + const { watch } = createWatch({ probeLoginSession: probe }) + watch.start() + expect(probeSignal?.aborted).toBe(false) + + watch.stop() + + expect(probeSignal?.aborted).toBe(true) + }) + + it('stop() prevents an in-flight resolver check from retiring the daemon', async () => { + let resolveHealth!: (health: SystemResolverHealth) => void + let resolverSignal: AbortSignal | undefined + const resolverHealth = new Promise((resolve) => { + resolveHealth = resolve + }) + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED], + readResolverHealth: (signal) => { + resolverSignal = signal + return resolverHealth + } + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) // retirement is now waiting on resolver health + + watch.stop() + expect(resolverSignal?.aborted).toBe(true) + resolveHealth('unhealthy') + await drainMicrotasks() + + expect(onRetire).not.toHaveBeenCalled() + }) + + it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => { + const outcomes: (() => Promise)[] = [ + async () => ACCEPTED, + async () => { + throw new Error('launchctl exploded') + }, + async () => ACCEPTED + ] + const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))()) + const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // throwing probe + await clock.advance(120_000) // rescheduled probe still runs + expect(probe).toHaveBeenCalledTimes(3) + expect(onRetire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts new file mode 100644 index 00000000000..d0cadccac18 --- /dev/null +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -0,0 +1,317 @@ +import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' +import type { DaemonFileLog } from './daemon-file-log' +import type { SystemResolverHealth } from './types' + +// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient +// rejection storm (PAM db reload, OS update) from retiring a daemon whose login +// session is still alive; a real logout rejects conclusively on every probe. +const REQUIRED_CONSECUTIVE_REJECTIONS = 3 +const PERIODIC_PROBE_MS = 120_000 +const REJECTION_RECHECK_MS = 10_000 +const PTY_EXIT_DEBOUNCE_MS = 2_000 +// Why: a client hello right after login is the fastest death signal for a stale +// daemon, but steady reconnects must not turn hellos into a PAM probe storm. +const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000 +const MIN_PROBE_GAP_MS = 5_000 + +type WatchClock = { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number +} + +export type MacosLoginSessionDeathWatchOptions = { + /** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */ + probeLoginSession: (signal?: AbortSignal) => Promise + readResolverHealth: (signal?: AbortSignal) => Promise + onRetire: (details: { + cause: 'pam-rejections' + rejections: number + resolverHealth: SystemResolverHealth + }) => void + log: DaemonFileLog + /** Direct-construction seam for deterministic tests; production uses real timers. */ + clock?: WatchClock + timing?: Partial<{ + periodicProbeMs: number + rejectionRecheckMs: number + ptyExitDebounceMs: number + clientActivityMinGapMs: number + minProbeGapMs: number + }> +} + +/** + * Detects that the macOS GUI login session this daemon was born into has died + * (full logout / WindowServer session teardown) and retires the daemon so the + * next app start cold-starts a replacement inside the live session (#7936). + * + * A daemon in a dead login session is unsalvageable: its PAM context can no + * longer host `login(1)` spawns ("Login incorrect" zombies) and its Mach + * bootstrap namespace has lost the system DNS resolver, so every terminal it + * hosts has no egress. Retirement is the only converging heal. + * + * The oracle is the existing TCC login-shell PAM probe: it conclusively accepts + * while the session is valid — including fast-user-switched-away sessions — and + * conclusively rejects once the session is destroyed. Retirement additionally + * requires the in-process system resolver to be explicitly degraded, so an + * inconclusive probe or PAM anomaly alone can never kill a healthy daemon. + */ +export class MacosLoginSessionDeathWatch { + private readonly probeLoginSession: MacosLoginSessionDeathWatchOptions['probeLoginSession'] + private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth'] + private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire'] + private readonly log: DaemonFileLog + private readonly clock: WatchClock + private readonly periodicProbeMs: number + private readonly rejectionRecheckMs: number + private readonly ptyExitDebounceMs: number + private readonly clientActivityMinGapMs: number + private readonly minProbeGapMs: number + + // Why: retire only a daemon that once proved its session could host login(1); + // a host where the wrapper never worked has no death signal to trust. + private armed = false + private consecutiveRejections = 0 + private lastProbeStartedAtMs: number | null = null + private probeInFlight = false + private stopped = false + private retired = false + private scheduledProbeTimer: unknown | null = null + private scheduledProbeAtMs: number | null = null + private ptyExitDebounceTimer: unknown | null = null + private pendingProbeTrigger: string | null = null + private probeAbortController: AbortController | null = null + + constructor(opts: MacosLoginSessionDeathWatchOptions) { + this.probeLoginSession = opts.probeLoginSession + this.readResolverHealth = opts.readResolverHealth + this.onRetire = opts.onRetire + this.log = opts.log + this.clock = opts.clock ?? { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } + this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS + this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS + this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS + this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS + this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS + } + + start(): void { + if (this.stopped) { + return + } + void this.runProbe('startup') + } + + stop(): void { + this.stopped = true + this.probeAbortController?.abort() + this.probeAbortController = null + if (this.scheduledProbeTimer !== null) { + this.clock.clearTimeout(this.scheduledProbeTimer) + this.scheduledProbeTimer = null + this.scheduledProbeAtMs = null + } + if (this.ptyExitDebounceTimer !== null) { + this.clock.clearTimeout(this.ptyExitDebounceTimer) + this.ptyExitDebounceTimer = null + } + } + + /** A mass PTY-exit burst is what a logout's SIGHUP sweep looks like from inside the daemon. */ + notifyPtyExit(): void { + if (this.stopped) { + return + } + if (this.ptyExitDebounceTimer !== null) { + this.clock.clearTimeout(this.ptyExitDebounceTimer) + } + this.ptyExitDebounceTimer = this.clock.setTimeout(() => { + this.ptyExitDebounceTimer = null + void this.runProbe('pty-exit') + }, this.ptyExitDebounceMs) + } + + notifyClientActivity(): void { + if (this.stopped) { + return + } + if (this.probeInFlight) { + if (this.armed) { + this.retainPendingProbe('client-hello') + } + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs + if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) { + // Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop. + this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello') + return + } + void this.runProbe('client-hello') + } + + private scheduleProbe(delayMs: number, trigger: string): void { + if (this.stopped) { + return + } + if (this.scheduledProbeTimer !== null) { + this.clock.clearTimeout(this.scheduledProbeTimer) + } + this.scheduledProbeAtMs = this.clock.now() + delayMs + this.scheduledProbeTimer = this.clock.setTimeout(() => { + this.scheduledProbeTimer = null + this.scheduledProbeAtMs = null + void this.runProbe(trigger) + }, delayMs) + } + + private scheduleNextProbe(delayMs: number): void { + this.scheduleProbe(delayMs, 'periodic') + } + + private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void { + const requestedAtMs = this.clock.now() + delayMs + if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) { + return + } + this.scheduleProbe(delayMs, trigger) + } + + private probeGapMs(trigger: string): number { + return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs + } + + private retainPendingProbe(trigger: string): void { + if ( + this.pendingProbeTrigger === null || + this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger) + ) { + this.pendingProbeTrigger = trigger + } + } + + private async runProbe(trigger: string): Promise { + if (this.stopped || this.retired) { + return + } + if (this.probeInFlight) { + // Why: the current probe may describe the pre-logout state; retain one follow-up without polling. + this.retainPendingProbe(trigger) + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs + if ( + trigger !== 'startup' && + elapsedSinceProbe !== null && + elapsedSinceProbe < this.minProbeGapMs + ) { + this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger) + return + } + this.probeInFlight = true + this.lastProbeStartedAtMs = this.clock.now() + const abortController = new AbortController() + this.probeAbortController = abortController + try { + const outcome = await this.probeLoginSession(abortController.signal) + if (this.stopped || this.retired) { + return + } + if (outcome === null) { + // Why: no wrapper machinery means no PAM oracle — watching would only ever misfire. + this.log.log('login-session-watch-disabled', { trigger }) + this.stop() + return + } + if (!outcome.conclusive) { + // Why: repeated timeouts are still ambiguous on slow/offline PAM hosts; + // never turn an inconclusive probe into authority to orphan live PTYs. + this.scheduleNextProbe(this.periodicProbeMs) + return + } + if (outcome.ok) { + if (!this.armed) { + this.log.log('login-session-watch-armed', { trigger }) + } + this.armed = true + this.consecutiveRejections = 0 + this.scheduleNextProbe(this.periodicProbeMs) + return + } + if (!this.armed) { + // Session never hosted login(1) here; the preflight already degraded spawns. + this.scheduleNextProbe(this.periodicProbeMs) + return + } + this.consecutiveRejections++ + this.log.log('login-session-probe-rejected', { + trigger, + rejections: this.consecutiveRejections + }) + if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) { + this.scheduleNextProbe(this.rejectionRecheckMs) + return + } + await this.retireIfResolverDegraded(() => { + this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 + }, abortController.signal) + } catch (error) { + // Why: a probe failure is diagnostic only; an escaped rejection would trip the + // daemon's fatal unhandled-error path and kill live terminals. + this.log.log('login-session-probe-error', { message: (error as Error)?.message }) + this.scheduleNextProbe(this.periodicProbeMs) + } finally { + this.probeInFlight = false + if (this.probeAbortController === abortController) { + this.probeAbortController = null + } + const pendingTrigger = this.pendingProbeTrigger + this.pendingProbeTrigger = null + if (!this.stopped && !this.retired && pendingTrigger !== null) { + const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now()) + this.scheduleProbeNoLaterThan( + Math.max(0, this.probeGapMs(pendingTrigger) - elapsed), + pendingTrigger + ) + } + } + } + + private async retireIfResolverDegraded( + holdAtThreshold: () => void, + signal: AbortSignal + ): Promise { + const resolverHealth = await this.readResolverHealth(signal) + if (this.stopped || this.retired) { + return + } + if (resolverHealth !== 'unhealthy') { + // Why: only explicit resolver degradation corroborates session death; unknown + // probe failures must preserve terminals and avoid a permanent fast retry loop. + this.log.log('login-session-retire-suppressed', { + cause: 'pam-rejections', + resolverHealth + }) + holdAtThreshold() + this.scheduleNextProbe(this.periodicProbeMs) + return + } + this.retired = true + this.onRetire({ + cause: 'pam-rejections', + rejections: this.consecutiveRejections, + resolverHealth + }) + } +} diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index 55496f9e50f..b483914bba3 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -18,6 +18,8 @@ export type TerminalHostOptions = { terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle + // Why: login-session death detection (#7936) needs subprocess exits even when no client is attached. + onSessionReaped?: (sessionId: string) => void // Why: graceful shutdown checkpoints must finish in-process before teardown. onFinalCheckpoint?: ( sessionId: string, diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index cee72eee1cf..e7e83e31a31 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -883,6 +883,8 @@ describe('TerminalHost', () => { }) it('does not list exited sessions', async () => { + const onSessionReaped = vi.fn() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped }) await host.createOrAttach({ sessionId: 'session-1', cols: 80, @@ -892,6 +894,7 @@ describe('TerminalHost', () => { lastSubprocess._onExitCb?.(0) expect(host.listSessions()).toEqual([]) + expect(onSessionReaped).toHaveBeenCalledWith('session-1') }) it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 796f1939fc6..6be96d27d8e 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -27,6 +27,7 @@ export class TerminalHost { private sessionTeardown = new TerminalSessionTeardown(this.sessions) private killedTombstones: TerminalHostTombstones private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + private onSessionReaped: TerminalHostOptions['onSessionReaped'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number private creationFenced = false @@ -36,6 +37,7 @@ export class TerminalHost { constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess + this.onSessionReaped = opts.onSessionReaped this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) @@ -119,6 +121,7 @@ export class TerminalHost { } session.dispose() this.sessions.delete(sessionId) + this.onSessionReaped?.(sessionId) } signal(sessionId: string, sig: string): void { diff --git a/src/main/index.ts b/src/main/index.ts index 494760c48c4..9ab35ae3b57 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -674,7 +674,11 @@ function startTerminalRuntimeStartupServices(): Promise { // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - await initDaemonPtyProvider(signal) + // Why: only GUI-spawned macOS daemons watch for login-session death; a headless + // serve daemon must survive its spawning session ending (SSH disconnect). + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode + }) logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) }, // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 7cd29d6e22b..804847e6bbf 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -132,6 +132,23 @@ resolver #1 expect(child.listenerCount('close')).toBe(0) }) + it('kills scutil and removes listeners when its owner stops', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + const abortController = new AbortController() + + const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal) + abortController.abort() + + await expect(healthPromise).resolves.toBe('unknown') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + it('removes scutil listeners when the child closes normally', async () => { mockPlatform('darwin') const child = createMockScutilProcess() diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 3545db1963c..9a3dd6f1eb6 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes return 'unknown' } -export async function readCurrentProcessMacSystemResolverHealth(): Promise { - if (process.platform !== 'darwin') { +export async function readCurrentProcessMacSystemResolverHealth( + signal?: AbortSignal +): Promise { + if (process.platform !== 'darwin' || signal?.aborted) { return 'unknown' } @@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { stderr += chunk } + const onAbort = (): void => { + child.kill('SIGKILL') + finish() + } const finish = (): void => { if (settled) { return @@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { @@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise ({ + execFileMock: vi.fn(), + existsSyncMock: vi.fn(), + stdinEndMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) + +import { runMacosLoginSessionPtyProbe } from './macos-login-session-pty-probe' + +type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void + +describe('runMacosLoginSessionPtyProbe', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true) + execFileMock.mockReset() + stdinEndMock.mockReset() + }) + + it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => { + const abortController = new AbortController() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect( + runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal) + ).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledWith( + '/usr/bin/expect', + [ + '-c', + expect.stringContaining( + 'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME)' + ) + ], + expect.objectContaining({ + cwd: '/Users/ada', + env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }), + killSignal: 'SIGKILL', + maxBuffer: 1_024, + signal: abortController.signal, + timeout: 4_000 + }), + expect.any(Function) + ) + expect(stdinEndMock).toHaveBeenCalledOnce() + expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof') + }) + + it('treats a natural exit without the marker as a conclusive rejection', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, 'Login incorrect\r\nlogin: ', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: true, + reason: 'rejected' + }) + }) + + it('keeps a timeout or output overflow inconclusive', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'timeout' + }) + + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback( + Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + }), + '', + '' + ) + return { stdin: { end: stdinEndMock } } + } + ) + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + }) + + it('does not mistake an expect wrapper failure for a PAM rejection', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('expect Tcl error'), { code: 1 }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + }) + + it('fails safe without spawning when expect is unavailable', async () => { + existsSyncMock.mockReturnValue(false) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/macos-login-session-pty-probe.ts b/src/main/providers/macos-login-session-pty-probe.ts new file mode 100644 index 00000000000..ffcc2098482 --- /dev/null +++ b/src/main/providers/macos-login-session-pty-probe.ts @@ -0,0 +1,78 @@ +import { execFile, type ExecFileException } from 'node:child_process' +import { existsSync } from 'node:fs' + +const MACOS_EXPECT_PATH = '/usr/bin/expect' +const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK' +const LOGIN_PROBE_USERNAME_ENV = 'ORCA_LOGIN_PROBE_USERNAME' +// Why: expect owns the PTY without adding a long-lived native handle to the daemon. +const EXPECT_LOGIN_PROBE_SCRIPT = + 'log_user 1; ' + + 'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME) /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK; ' + + 'send "\\004"; expect eof; wait; exit 0' + +export type LoginPreflightOutcome = { + ok: boolean + conclusive: boolean + reason: 'accepted' | 'rejected' | 'timeout' | 'error' +} + +export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome { + // Why: a probe killed by our bound proves nothing about PAM and must not stick. + if (error.killed || error.code === 'ETIMEDOUT') { + return { ok: false, conclusive: false, reason: 'timeout' } + } + // Why: a natural nonzero exit is login(1)'s conclusive rejection verdict. + if (typeof error.code === 'number') { + return { ok: false, conclusive: true, reason: 'rejected' } + } + return { ok: false, conclusive: false, reason: 'error' } +} + +/** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */ +export function runMacosLoginSessionPtyProbe( + username: string, + accountHome: string, + timeoutMs: number, + maxOutputBytes: number, + signal?: AbortSignal +): Promise { + if (!existsSync(MACOS_EXPECT_PATH)) { + return Promise.resolve({ ok: false, conclusive: false, reason: 'error' }) + } + return new Promise((resolve) => { + try { + const child = execFile( + MACOS_EXPECT_PATH, + ['-c', EXPECT_LOGIN_PROBE_SCRIPT], + { + cwd: accountHome, + encoding: 'utf8', + env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username }, + killSignal: 'SIGKILL', + maxBuffer: maxOutputBytes, + signal, + timeout: timeoutMs + }, + (error, stdout) => { + if (error !== null) { + // Why: a nonzero expect exit can be its own Tcl/PTY failure, not PAM authority. + resolve( + error.killed || error.code === 'ETIMEDOUT' + ? { ok: false, conclusive: false, reason: 'timeout' } + : { ok: false, conclusive: false, reason: 'error' } + ) + return + } + resolve( + stdout.includes(LOGIN_PREFLIGHT_MARKER) + ? { ok: true, conclusive: true, reason: 'accepted' } + : { ok: false, conclusive: true, reason: 'rejected' } + ) + } + ) + child.stdin?.end() + } catch { + resolve({ ok: false, conclusive: false, reason: 'error' }) + } + }) +} diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 565d40da9dd..6f1a66a90d3 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -1,23 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe' -const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - userInfoMock: vi.fn(), - execFileMock: vi.fn(), - stdinEndMock: vi.fn() -})) +const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted( + () => ({ + existsSyncMock: vi.fn(), + userInfoMock: vi.fn(), + execFileMock: vi.fn(), + stdinEndMock: vi.fn(), + ptyProbeMock: vi.fn() + }) +) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) vi.mock('node:os', () => ({ userInfo: userInfoMock })) vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({ + ...(await importOriginal()), + runMacosLoginSessionPtyProbe: ptyProbeMock +})) import { prepareMacosTccLoginShell, + probeMacosLoginSessionAlive, resetMacosLoginShellPreflightForTests, wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void +const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const +const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const describe('wrapShellSpawnForMacosTccAttribution', () => { let origPlatform: PropertyDescriptor | undefined @@ -39,6 +50,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { return { stdin: { end: stdinEndMock } } } ) + ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' }) resetMacosLoginShellPreflightForTests() }) @@ -340,3 +352,200 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { expect(execFileMock).not.toHaveBeenCalled() }) }) + +describe('probeMacosLoginSessionAlive', () => { + let origPlatform: PropertyDescriptor | undefined + let origDisable: string | undefined + + function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) + } + + beforeEach(() => { + origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + existsSyncMock.mockReturnValue(true) + userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' }) + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' }) + resetMacosLoginShellPreflightForTests() + }) + + afterEach(() => { + if (origPlatform) { + Object.defineProperty(process, 'platform', origPlatform) + } + if (origDisable === undefined) { + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + } else { + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable + } + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('re-probes even after a cached acceptance', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(1) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => { + setPlatform('darwin') + let finishPreflight!: ExecFileCallback + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + finishPreflight = callback + return { stdin: { end: stdinEndMock } } + } + ) + + const warmup = prepareMacosTccLoginShell() + const freshProbe = probeMacosLoginSessionAlive() + expect(execFileMock).toHaveBeenCalledOnce() + + finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([ + ACCEPTED_OUTCOME, + ACCEPTED_OUTCOME + ]) + expect(execFileMock).toHaveBeenCalledOnce() + }) + + it('does not let a spawn-path probe overwrite a newer death verdict', async () => { + setPlatform('darwin') + const callbacks: ExecFileCallback[] = [] + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callbacks.push(callback) + return { stdin: { end: stdinEndMock } } + } + ) + + const freshProbe = probeMacosLoginSessionAlive() + const spawnProbe = prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(2) + + callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME) + callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME) + + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) + // The dead-session daemon must stop minting login(1) prompt zombies (#7936). + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('does not overwrite the cached verdict on an inconclusive probe', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('returns null off macOS and when disabled', async () => { + setPlatform('linux') + expect(await probeMacosLoginSessionAlive()).toBeNull() + setPlatform('darwin') + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1' + expect(await probeMacosLoginSessionAlive()).toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => { + setPlatform('darwin') + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledOnce() + expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('stays inconclusive when both pipe and PTY probes time out', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) + // Inconclusive must not disturb the cached acceptance. + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('does not start a PTY fallback after the watch cancels its pipe probe', async () => { + setPlatform('darwin') + const abortController = new AbortController() + abortController.abort() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + expect(ptyProbeMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index bd92cacaac5..9ee3a709314 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -1,11 +1,21 @@ -import { execFile, type ExecFileException } from 'node:child_process' +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { userInfo } from 'node:os' +import { + classifyLoginPreflightError, + runMacosLoginSessionPtyProbe, + type LoginPreflightOutcome +} from './macos-login-session-pty-probe' + +export type { LoginPreflightOutcome } from './macos-login-session-pty-probe' const MACOS_LOGIN_PATH = '/usr/bin/login' const MACOS_ENV_PATH = '/usr/bin/env' const MACOS_PRINTF_PATH = '/usr/bin/printf' const LOGIN_PREFLIGHT_TIMEOUT_MS = 500 +// Why: the death-watch probe runs off the spawn path, so it can afford a bound +// that outlasts a PAM stack answering slowly rather than misreading it as a hang. +const LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS = 4_000 const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK' const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024 const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000 @@ -23,15 +33,11 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL' * reject) that may be cached; an inconclusive probe (our own timeout/SIGKILL, * maxBuffer, or spawn error) proves nothing about PAM and must not stick. */ -export type LoginPreflightOutcome = { - ok: boolean - conclusive: boolean - reason: 'accepted' | 'rejected' | 'timeout' | 'error' -} - let cachedLoginPreflightResult: boolean | null = null let loginPreflightInFlight: Promise | null = null let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null +let loginPreflightCacheEpoch = 0 +let loginSessionProbeInFlight = false function isDisabledByEnv(): boolean { const value = process.env[DISABLE_ENV_VAR] @@ -45,25 +51,15 @@ function loginPreflightRetryDelayMs(failureCount: number): number { ) } -function classifyPreflightError(error: ExecFileException): LoginPreflightOutcome { - // Why: our SIGKILL timeout cap (and maxBuffer, which also kills) is an - // environmental slow-path, not a PAM verdict — retry, don't cache (F1). - if (error.killed || error.code === 'ETIMEDOUT') { - return { ok: false, conclusive: false, reason: 'timeout' } - } - // A numeric exit code means login(1) ran to completion and rejected the user - // (it exits immediately on EOF-driven rejection); that verdict is cacheable. - if (typeof error.code === 'number') { - return { ok: false, conclusive: true, reason: 'rejected' } - } - // Spawn/EOF/other failure: inconclusive, fail open for this spawn but retry. - return { ok: false, conclusive: false, reason: 'error' } -} - // Fidelity limit: the probe runs over pipes while production shells run under a // real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe // pass with a prod failure only degrades to today's direct spawn (no wrapper). -function runLoginPreflight(username: string, accountHome: string): Promise { +function runLoginPreflight( + username: string, + accountHome: string, + timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS, + signal?: AbortSignal +): Promise { return new Promise((resolve) => { try { const child = execFile( @@ -78,7 +74,8 @@ function runLoginPreflight(username: string, accountHome: string): Promise { if (error === null) { @@ -91,7 +88,7 @@ function runLoginPreflight(username: string, accountHome: string): Promise { // Why: cache only a conclusive PAM verdict; a killed/timed-out probe is // environmental and must be retried next spawn, not stuck forever (F1). - if (outcome.conclusive) { + const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch + if (outcome.conclusive && mayUpdateCache) { cachedLoginPreflightResult = outcome.ok transientLoginPreflightFailure = null - } else { + } else if (!outcome.conclusive && mayUpdateCache) { const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1 transientLoginPreflightFailure = { failureCount, @@ -192,6 +191,64 @@ export function resetMacosLoginShellPreflightForTests(): void { cachedLoginPreflightResult = null loginPreflightInFlight = null transientLoginPreflightFailure = null + loginPreflightCacheEpoch = 0 + loginSessionProbeInFlight = false +} + +/** + * Fresh PAM probe for login-session death detection (#7936): bypasses the + * cached verdict and the transient backoff, and writes any conclusive verdict + * back into the cache — so a daemon whose login session died stops wrapping + * spawns in `login(1)` (which would only mint "Login incorrect" zombies) even + * before retirement completes. Escalates to a PTY-hosted probe when the pipe + * probe is inconclusive, since a dead session's PAM stack may only misbehave + * under a real tty. Returns null when the wrapper doesn't apply. + */ +export async function probeMacosLoginSessionAlive( + signal?: AbortSignal +): Promise { + if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) { + return null + } + let username: string + let accountHome: string + try { + const account = userInfo() + username = account.username + accountHome = account.homedir + } catch { + return null + } + if (!username || !accountHome) { + return null + } + // Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict. + const existingPreflight = loginPreflightInFlight + loginSessionProbeInFlight = true + loginPreflightCacheEpoch++ + let outcome: LoginPreflightOutcome + try { + outcome = await (existingPreflight ?? + runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal)) + if (!outcome.conclusive && !signal?.aborted) { + outcome = await runMacosLoginSessionPtyProbe( + username, + accountHome, + LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, + LOGIN_PREFLIGHT_MAX_BUFFER_BYTES, + signal + ) + } + } finally { + // Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict. + loginPreflightCacheEpoch++ + loginSessionProbeInFlight = false + } + if (outcome.conclusive) { + cachedLoginPreflightResult = outcome.ok + transientLoginPreflightFailure = null + } + return outcome } /** From 8b6e530ed2c81a7d97e98c6643b0abe0a6e68620 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:54:36 +0000 Subject: [PATCH 18/29] release: v1.4.151-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02b58ce82ef..918c0f00e6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.151-rc.0", + "version": "1.4.151-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 9bdb8204e130eb9e16a8458b8cd727598fad209c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:10:29 -0700 Subject: [PATCH 19/29] test(e2e): verify Claude is prefilled with issue URL on start (#9980) * test(e2e): verify Claude is prefilled with issue URL on start Regression test for #6613: when starting a workspace from a newly created GitHub issue, ensure the issue URL is passed to Claude via `--prefill` and `--dangerously-skip-permissions` flags. This prevents context loss after issue creation. * test(e2e): fix GitHub-issue-start prefill test flakiness - Reorder mock API handlers to ensure `/labels` and `/assignees` paths match before the specific issue endpoint - Replace regex heading matcher with exact string for more reliable assertions - Refactor terminal content polling to capture text once and reuse in subsequent assertions --- ...github-created-issue-start-prefill.spec.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/e2e/github-created-issue-start-prefill.spec.ts diff --git a/tests/e2e/github-created-issue-start-prefill.spec.ts b/tests/e2e/github-created-issue-start-prefill.spec.ts new file mode 100644 index 00000000000..c3e6d40820a --- /dev/null +++ b/tests/e2e/github-created-issue-start-prefill.spec.ts @@ -0,0 +1,187 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test as base, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { getTerminalContent } from './helpers/terminal' + +const ISSUE_NUMBER = 6613 +const ISSUE_TITLE = 'Start a newly created issue without losing its context' +const ISSUE_URL = `https://github.com/acme/repo/issues/${ISSUE_NUMBER}` +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-created-issue-prefill-')) + +const fakeGhSource = ` +const args = process.argv.slice(2) +const joined = args.join(' ') +const issue = { + number: ${ISSUE_NUMBER}, + title: ${JSON.stringify(ISSUE_TITLE)}, + state: 'open', + html_url: ${JSON.stringify(ISSUE_URL)}, + labels: [], + assignees: [], + user: { login: 'e2e' }, + updated_at: '2026-07-22T12:00:00.000Z' +} + +if (args[0] === 'auth' && args[1] === 'status') { + console.error('github.com\\n ✓ Logged in to github.com account e2e (GITHUB_TOKEN)') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'user') { + console.log(JSON.stringify({ login: 'e2e' })) + process.exit(0) +} +if (args[0] === 'api' && args.includes('rate_limit')) { + console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } })) + process.exit(0) +} +if (args[0] === 'api' && args.includes('-X') && args.includes('POST') && joined.includes('repos/acme/repo/issues')) { + console.log(JSON.stringify(issue)) + process.exit(0) +} +if (args[0] === 'api' && joined.includes('/labels')) { + process.exit(0) +} +if (args[0] === 'api' && joined.includes('/assignees')) { + process.exit(0) +} +if (args[0] === 'api' && joined.includes('repos/acme/repo/issues/${ISSUE_NUMBER}')) { + console.log(JSON.stringify(issue)) + process.exit(0) +} +if (args[0] === 'api' && joined.includes('search/issues')) { + console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] })) + process.exit(0) +} +if (args[0] === 'issue' && args[1] === 'list') { + console.log('[]') + process.exit(0) +} +if (args[0] === 'pr' && args[1] === 'list') { + console.log('[]') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'graphql') { + console.log(JSON.stringify({ data: { search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } } })) + process.exit(0) +} +console.error('fake gh: unhandled ' + joined) +process.exit(1) +` + +const fakeClaudeSource = ` +const args = process.argv.slice(2) +process.stdout.write('E2E_CLAUDE_ARGV ' + JSON.stringify(args) + '\\n') +setInterval(() => {}, 60_000) +` + +function installFakeCli(name: 'gh' | 'claude', source: string): void { + if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, `fake-${name}.js`), source) + writeFileSync( + path.join(fakeCliDir, `${name}.cmd`), + `@echo off\r\nnode "%~dp0\\fake-${name}.js" %*\r\n` + ) + return + } + const executable = path.join(fakeCliDir, name) + writeFileSync(executable, `#!/usr/bin/env node\n${source}`) + chmodSync(executable, 0o755) +} + +installFakeCli('gh', fakeGhSource) +installFakeCli('claude', fakeClaudeSource) + +const test = base.extend({ + launchEnv: [ + { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}` + }, + { option: true } + ] +}) + +test.afterAll(() => { + rmSync(fakeCliDir, { recursive: true, force: true }) +}) + +function configureGitHubRemote(repoPath: string): void { + try { + execFileSync('git', ['remote', 'remove', 'origin'], { cwd: repoPath, stdio: 'ignore' }) + } catch { + // The disposable E2E repo does not have an origin on its first run. + } + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/repo.git'], { + cwd: repoPath, + stdio: 'pipe' + }) +} + +test('starting a just-created GitHub issue launches Claude with its URL prefilled', async ({ + orcaPage, + testRepoPath +}) => { + configureGitHubRemote(testRepoPath) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const preparedWorkspace = state + .allWorktrees() + .find((worktree) => worktree.branch?.endsWith('e2e-secondary')) + if (!preparedWorkspace) { + throw new Error('Seeded secondary E2E worktree is not available') + } + // Why: this regression owns renderer-to-PTY command propagation, while the shared fixture already covers Git worktree creation. + store.setState({ + createWorktree: async () => ({ worktree: preparedWorkspace }) + }) + await state.updateSettings({ + defaultTuiAgent: 'claude', + disabledTuiAgents: [], + ...(navigator.userAgent.includes('Windows') ? { terminalWindowsShell: 'git-bash' } : {}) + }) + store.getState().openTaskPage({ taskSource: 'github' }) + }) + + const newIssueButton = orcaPage.getByRole('button', { name: 'New GitHub issue' }) + await expect(newIssueButton).toBeEnabled({ timeout: 15_000 }) + await newIssueButton.click() + + const createDialog = orcaPage.getByRole('dialog', { name: 'New GitHub issue' }) + await expect(createDialog).toBeVisible() + await createDialog.getByPlaceholder('Short summary').fill(ISSUE_TITLE) + await createDialog.getByRole('button', { name: 'Create issue' }).click() + + await expect(createDialog).toBeHidden({ timeout: 10_000 }) + await expect(orcaPage.getByRole('heading', { name: ISSUE_TITLE })).toBeVisible({ + timeout: 10_000 + }) + + await orcaPage.getByRole('button', { name: 'Start workspace from issue' }).click() + + let terminalText = '' + await expect + .poll( + async () => { + terminalText = await getTerminalContent(orcaPage, 12_000) + return terminalText + }, + { + timeout: 30_000, + message: 'Claude prefill command did not reach the active terminal buffer' + } + ) + .toContain('--prefill') + expect(terminalText).toContain('--dangerously-skip-permissions') + expect(terminalText).toContain('--prefill') + expect(terminalText).toContain(ISSUE_URL) + expect(terminalText).not.toMatch(/(?:^|\n)claude '--dangerously-skip-permissions'(?:\r?\n|$)/) +}) From 0d82cc3dbb6ed5bb288b48a8e6864b944da73add Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 22 Jul 2026 18:52:42 -0400 Subject: [PATCH 20/29] Fix legacy worktree lineage projection after stable updates (#9913) --- src/main/ipc/worktrees.test.ts | 121 ++++++++ src/main/ipc/worktrees.ts | 12 +- src/main/runtime/orca-runtime.test.ts | 223 ++++++++++++++- src/main/runtime/orca-runtime.ts | 60 ++-- ...older-workspace-attached-worktrees.test.ts | 107 ++++++++ .../folder-workspace-attached-worktrees.ts | 75 ++--- .../sidebar/WorktreeContextMenu.test.ts | 22 ++ .../sidebar/WorktreeContextMenu.tsx | 64 +++-- .../src/components/sidebar/WorktreeList.tsx | 88 +++--- .../folder-workspace-card-pr-display.test.ts | 103 +++++++ .../folder-workspace-card-pr-display.ts | 37 +-- ...e-visible-workspace-kanban-worktree-ids.ts | 3 +- .../sidebar/visible-worktrees.test.ts | 144 +++++++++- .../components/sidebar/visible-worktrees.ts | 38 ++- .../sidebar/workspace-delete-lineage.test.ts | 65 +++++ .../sidebar/workspace-delete-lineage.ts | 30 +- .../worktree-lineage-drag-drop.test.ts | 107 +++++++- .../sidebar/worktree-lineage-drag-drop.ts | 16 +- .../sidebar/worktree-lineage-projection.ts | 100 +++++++ .../sidebar/worktree-list-groups.test.ts | 259 +++++++++++++++++- .../sidebar/worktree-list-groups.ts | 54 ++-- .../sidebar/worktree-parent-candidates.ts | 59 +++- .../worktree-parent-eligibility.test.ts | 30 +- .../sidebar/worktree-parent-eligibility.ts | 17 +- .../src/store/slices/worktrees.test.ts | 94 +++++++ src/renderer/src/store/slices/worktrees.ts | 68 ++++- src/shared/resolved-worktree-lineage.test.ts | 171 ++++++++++++ src/shared/resolved-worktree-lineage.ts | 108 ++++++++ tests/e2e/worktree-lineage-state.ts | 49 ++-- tests/e2e/worktree-lineage.spec.ts | 37 +++ 30 files changed, 2027 insertions(+), 334 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-projection.ts create mode 100644 src/shared/resolved-worktree-lineage.test.ts create mode 100644 src/shared/resolved-worktree-lineage.ts diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index bcbc940f72e..6e126be2091 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => { }) }) + it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { + const parentPath = '/workspace/assigned-issues' + const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing' + const parentId = `repo-1::${parentPath}` + const childId = `repo-1::${childPath}` + const metaById: Record = { + [parentId]: { instanceId: 'parent-instance' }, + [childId]: { instanceId: 'child-instance' } + } + store.getWorktreeMeta.mockImplementation((id: string) => metaById[id]) + store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({ + ...metaById[id], + ...updates + })) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + }) + listWorktreesMock.mockResolvedValue([ + { + path: childPath, + head: 'child-head', + branch: 'refs/heads/child', + isBare: false, + isMainWorktree: false + }, + { + path: parentPath, + head: 'parent-head', + branch: 'refs/heads/parent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }), + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + ]) + }) + + it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => { + const folderRepo = { + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + const parentId = `${folderRepo.id}::${folderRepo.path}` + const childId = `${parentId}::workspace:child-instance` + const metaById: Record> = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }), + [childId]: makeWorktreeMeta({ + instanceId: 'child-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getRepo.mockReturnValue(folderRepo) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + }) + + const result = (await handlers['worktrees:listDetected'](null, { + repoId: folderRepo.id + })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }), + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }) + ]) + }) + it('hides agent scratch created inside a linked checkout from desktop listings', async () => { const linkedCheckoutPath = '/workspace/feature-x' const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ee878ae0926..40cf7efd17d 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -13,6 +13,7 @@ import { } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' +import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { AutomationWorkspaceProvenance, @@ -738,7 +739,7 @@ function buildDetectedGitWorktrees( repo.path, ...liveWorktrees.map((worktree) => worktree.path) ]) - return liveWorktrees.map((gitWorktree) => { + const detected = liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -766,6 +767,7 @@ function buildDetectedGitWorktrees( agentScratchWorktreePathMatcher }) }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } function stampAndMergeVisibleDetectedWorktree( @@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees( repo.path, ...worktrees.map((worktree) => worktree.path) ]) - return worktrees.map((worktree) => { + const detected = worktrees.map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) const detected = toDetectedWorktree({ repo, @@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees( }) return applyMetadataFallbackVisibility(detected) }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } export function registerWorktreeHandlers( @@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers( repoId: repo.id, authoritative: true, source: 'git', - worktrees: buildFolderDetectedWorktrees(store, repo) + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) } } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3a5099edde2..897a2fcb8ec 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync } from 'node:fs' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { join, win32 } from 'node:path' +import { basename, join, win32 } from 'node:path' import { ipcMain } from 'electron' import type { FolderWorkspace, @@ -24854,19 +24854,34 @@ describe('OrcaRuntimeService', () => { }) }) - it('emits only instance-validated lineage parents in mobile summaries', async () => { + it('emits only instance- and boundary-validated lineage parents in mobile summaries', async () => { // Regression: shipped mobile clients trust parentWorktreeId blindly, so worktree.ps must not emit stale same-path lineage. - const parentPath = '/tmp/worktree-parent' - const validChildPath = '/tmp/worktree-child-valid' - const staleChildPath = '/tmp/worktree-child-stale' + const parentPath = join(tmpdir(), 'worktree-parent') + const validChildPath = join(tmpdir(), 'worktree-child-valid') + const staleChildPath = join(tmpdir(), 'worktree-child-stale') + const crossHostChildPath = join(tmpdir(), 'worktree-child-cross-host') const parentId = `${TEST_REPO_ID}::${parentPath}` const validChildId = `${TEST_REPO_ID}::${validChildPath}` const staleChildId = `${TEST_REPO_ID}::${staleChildPath}` + const crossHostChildId = `${TEST_REPO_ID}::${crossHostChildPath}` const metaById: Record = { - [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), - [validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + hostId: 'local', + projectId: 'project-a' + }), + [validChildId]: makeWorktreeMeta({ + instanceId: 'child-instance', + hostId: 'local', + projectId: 'project-a' + }), // The stale child path was reused by a replacement checkout. - [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }) + [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }), + [crossHostChildId]: makeWorktreeMeta({ + instanceId: 'cross-host-child-instance', + hostId: 'runtime:other-host', + projectId: 'project-a' + }) } const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({ worktreeId: childId, @@ -24879,7 +24894,8 @@ describe('OrcaRuntimeService', () => { }) const lineageById: Record = { [validChildId]: makeLineage(validChildId, 'child-instance'), - [staleChildId]: makeLineage(staleChildId, 'old-child-instance') + [staleChildId]: makeLineage(staleChildId, 'old-child-instance'), + [crossHostChildId]: makeLineage(crossHostChildId, 'cross-host-child-instance') } const runtimeStore = { ...store, @@ -24893,10 +24909,10 @@ describe('OrcaRuntimeService', () => { getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId] } vi.mocked(listWorktrees).mockResolvedValue( - [parentPath, validChildPath, staleChildPath].map((path) => ({ + [parentPath, validChildPath, staleChildPath, crossHostChildPath].map((path) => ({ path, head: 'abc', - branch: `feature/${path.split('/').pop()}`, + branch: `feature/${basename(path)}`, isBare: false, isMainWorktree: false })) @@ -24918,6 +24934,13 @@ describe('OrcaRuntimeService', () => { }) expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined() expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined() + const crossHostSummary = worktrees.find((worktree) => worktree.worktreeId === crossHostChildId) + expect(crossHostSummary).toMatchObject({ + parentWorktreeId: null, + worktreeInstanceId: 'cross-host-child-instance' + }) + expect(crossHostSummary?.lineageWorktreeInstanceId).toBeUndefined() + expect(crossHostSummary?.parentWorktreeInstanceId).toBeUndefined() expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({ childWorktreeIds: [validChildId] }) @@ -28051,6 +28074,80 @@ describe('OrcaRuntimeService', () => { ) }) + it.each([ + { + boundary: 'repository', + childRepoId: 'repo-child', + parentRepoId: 'repo-parent', + childMeta: {}, + parentMeta: {} + }, + { + boundary: 'known host', + childRepoId: TEST_REPO_ID, + parentRepoId: TEST_REPO_ID, + childMeta: { hostId: 'runtime:child-host' as const }, + parentMeta: { hostId: 'runtime:parent-host' as const } + }, + { + boundary: 'known project', + childRepoId: TEST_REPO_ID, + parentRepoId: TEST_REPO_ID, + childMeta: { projectId: 'project-child' }, + parentMeta: { projectId: 'project-parent' } + } + ])('rejects manual lineage writes across a $boundary boundary', async (scenario) => { + const repos = [...new Set([scenario.childRepoId, scenario.parentRepoId])].map((id) => ({ + id, + path: join(tmpdir(), id), + displayName: id, + badgeColor: 'blue' as const, + addedAt: 1 + })) + const childRepoPath = repos.find((repo) => repo.id === scenario.childRepoId)!.path + const parentRepoPath = repos.find((repo) => repo.id === scenario.parentRepoId)!.path + const childPath = join(childRepoPath, 'child') + const parentPath = join(parentRepoPath, 'parent') + const childId = `${scenario.childRepoId}::${childPath}` + const parentId = `${scenario.parentRepoId}::${parentPath}` + const metaById: Record = { + [childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }), + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta }) + } + const setWorktreeLineage = vi.fn() + const setWorkspaceLineage = vi.fn() + const runtimeStore = { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage, + setWorkspaceLineage + } + vi.mocked(listWorktrees).mockImplementation(async (repoPath) => [ + ...(repoPath === childRepoPath ? [makeWorktreeInfo(childPath)] : []), + ...(repoPath === parentRepoPath ? [makeWorktreeInfo(parentPath)] : []) + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect( + runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + ).rejects.toThrow( + 'Parent worktree must belong to the same repository, execution host, and project.' + ) + + expect(setWorktreeLineage).not.toHaveBeenCalled() + expect(setWorkspaceLineage).not.toHaveBeenCalled() + }) + it('clears workspace lineage when manually removing a parent', async () => { const childPath = '/tmp/worktree-child' const childId = `${TEST_REPO_ID}::${childPath}` @@ -28377,6 +28474,110 @@ describe('OrcaRuntimeService', () => { expect(removeWorktreeLineage).not.toHaveBeenCalled() }) + it('hydrates runtime detected lists with instance-validated legacy lineage', async () => { + const parentPath = join(tmpdir(), 'worktree-parent') + const childPath = join(tmpdir(), 'worktree-child') + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getAllWorktreeLineage: () => lineageById + } as never) + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(childPath), + makeWorktreeInfo(parentPath) + ]) + + const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }), + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + ]) + }) + + it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => { + const folderRepo = { + id: 'folder-repo', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: 'blue' as const, + addedAt: 1, + kind: 'folder' as const + } + const parentId = `${folderRepo.id}::${folderRepo.path}` + const childId = `${parentId}::workspace:child-instance` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [folderRepo], + getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById + } as never) + + const result = await runtime.listDetectedManagedWorktrees(`id:${folderRepo.id}`) + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }), + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }) + ]) + }) + it('hides agent scratch created inside a linked checkout from runtime listings', async () => { const linkedCheckoutPath = '/tmp/worktree-a' const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bbeba293b58..5bab178d41b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -301,6 +301,10 @@ import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { + projectResolvedWorktreeLineage, + sharesResolvedWorktreeLineageBoundary +} from '../../shared/resolved-worktree-lineage' import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { FolderWorkspacePathStatus, @@ -16555,13 +16559,15 @@ export class OrcaRuntimeService { async listDetectedManagedWorktrees(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) + const store = this.requireStore() if (isFolderRepo(repo)) { - const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo) + const worktrees = listRuntimeFolderWorkspaces(store, repo) + const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) return { repoId: repo.id, authoritative: true, source: 'git', - worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } let scan: RuntimeWorktreeScanResult @@ -16579,7 +16585,7 @@ export class OrcaRuntimeService { ]) const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = this.store?.getWorktreeMeta(worktreeId) + const meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) const detectedWorktree = this.toRuntimeDetectedWorktree( repo, @@ -16595,7 +16601,7 @@ export class OrcaRuntimeService { repoId: repo.id, authoritative: scan.ok, source: scan.ok ? 'git' : 'metadata-fallback', - worktrees: detected + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } @@ -23152,6 +23158,12 @@ export class OrcaRuntimeService { if (childWorktreeId === parentWorktreeId) { throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.') } + if (!sharesResolvedWorktreeLineageBoundary(child, parent)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Parent worktree must belong to the same repository, execution host, and project.' + ) + } const instanceByWorktreeId = new Map( this.resolvedWorktreeCache?.worktrees.map((worktree) => [ worktree.id, @@ -23722,7 +23734,10 @@ export class OrcaRuntimeService { }) }) ) - const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat()) + const worktrees = projectResolvedWorktreeLineage( + perRepoWorktrees.flat(), + this.store?.getAllWorktreeLineage?.() ?? {} + ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { @@ -23734,41 +23749,6 @@ export class OrcaRuntimeService { return { worktrees, platformByRepoId } } - private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] { - const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} - const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) - const validLineageByChildId = new Map() - const childIdsByParentId = new Map() - - for (const [childId, lineage] of Object.entries(lineageById)) { - const child = worktreeById.get(childId) - const parent = worktreeById.get(lineage.parentWorktreeId) - if ( - !child || - !parent || - child.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - // Why: worktree IDs are path-derived, so instance checks keep replacement checkouts off stale same-path lineage. - continue - } - validLineageByChildId.set(childId, lineage) - const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] - children.push(childId) - childIdsByParentId.set(lineage.parentWorktreeId, children) - } - - return worktrees.map((worktree) => { - const lineage = validLineageByChildId.get(worktree.id) ?? null - return { - ...worktree, - parentWorktreeId: lineage?.parentWorktreeId ?? null, - childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], - lineage - } - }) - } - private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { const store = this.store if ( diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts index 3244e5c82e5..ed765b0886d 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts @@ -6,6 +6,7 @@ import type { WorkspaceLineage } from '../../../../shared/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' function makeFolder(id = 'folder-1'): FolderWorkspace { @@ -166,4 +167,110 @@ describe('getAttachedWorktreesForFolderWorkspace', () => { [nested.id] ) }) + + it('includes an exact inline-only legacy descendant under an attached root', () => { + const parent = makeWorktree({ + id: 'repo-1::/parent', + instanceId: 'parent' + }) + const nested = makeWorktree({ + id: 'repo-1::/nested', + instanceId: 'nested' + }) + const inlineNested = { + ...nested, + lineage: makeWorktreeLineage(nested, parent) + } as Worktree + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: {}, + worktreesByRepo: { 'repo-1': [parent, inlineNested] } + }) + + expect(result.lineageChildrenByParentId.get(parent.id)?.map((worktree) => worktree.id)).toEqual( + [nested.id] + ) + }) + + it('keeps a stale side-map entry authoritative over valid inline lineage', () => { + const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' }) + const inlineNested = { + ...nested, + lineage: makeWorktreeLineage(nested, parent) + } as Worktree + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [nested.id]: { + ...makeWorktreeLineage(nested, parent), + parentWorktreeInstanceId: 'stale-parent' + } + }, + worktreesByRepo: { 'repo-1': [parent, inlineNested] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + }) + + it('rejects nested descendants across host or project boundaries', () => { + const parent = makeWorktree({ + id: 'repo-1::/parent', + instanceId: 'parent', + hostId: LOCAL_EXECUTION_HOST_ID, + projectId: 'project-1' + }) + const hostChild = makeWorktree({ + id: 'repo-1::/host-child', + instanceId: 'host-child', + hostId: toSshExecutionHostId('other') + }) + const projectChild = makeWorktree({ + id: 'repo-1::/project-child', + instanceId: 'project-child', + projectId: 'project-2' + }) + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [hostChild.id]: makeWorktreeLineage(hostChild, parent), + [projectChild.id]: makeWorktreeLineage(projectChild, parent) + }, + worktreesByRepo: { 'repo-1': [parent, hostChild, projectChild] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + }) + + it('does not attach cyclic legacy descendants', () => { + const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' }) + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [parent.id]: makeWorktreeLineage(parent, nested), + [nested.id]: makeWorktreeLineage(nested, parent) + }, + worktreesByRepo: { 'repo-1': [parent, nested] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + expect(result.rootChildWorktrees.map((worktree) => worktree.id)).toEqual([parent.id]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts index 97999e97cac..ec65782b488 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts @@ -6,6 +6,7 @@ import type { WorkspaceLineage } from '../../../../shared/types' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' +import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection' export type AttachedWorktreeResolution = { folderWorkspace: FolderWorkspace | null @@ -90,39 +91,30 @@ export function getLineageChildrenByParentId( worktreeById: Map, rootWorktreeIds: ReadonlySet ): Map { - const descendantsByParentId = new Map() + const projectedChildrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + lineageById, + worktreeById + ) const includedIds = new Set(rootWorktreeIds) - let added = true - - while (added) { - added = false - for (const lineage of Object.values(lineageById)) { - const parent = worktreeById.get(lineage.parentWorktreeId) - const child = worktreeById.get(lineage.worktreeId) - if (!isValidLineageChild(parent, child, lineage, includedIds)) { + const queue = [...rootWorktreeIds] + for (let index = 0; index < queue.length; index += 1) { + for (const child of projectedChildrenByParentId.get(queue[index]) ?? []) { + if (child.isArchived || includedIds.has(child.id)) { continue } includedIds.add(child.id) - added = true + queue.push(child.id) } } - for (const worktreeId of includedIds) { - const child = worktreeById.get(worktreeId) - if (!child) { - continue + const descendantsByParentId = new Map() + for (const parentId of includedIds) { + const children = (projectedChildrenByParentId.get(parentId) ?? []).filter( + (child) => includedIds.has(child.id) && !child.isArchived + ) + if (children.length > 0) { + descendantsByParentId.set(parentId, children) } - const lineage = lineageById[child.id] - if (!lineage || !includedIds.has(lineage.parentWorktreeId)) { - continue - } - const parent = worktreeById.get(lineage.parentWorktreeId) - if (!isCurrentLineagePair(parent, child, lineage)) { - continue - } - const children = descendantsByParentId.get(parent.id) ?? [] - children.push(child) - descendantsByParentId.set(parent.id, children) } for (const children of descendantsByParentId.values()) { @@ -160,39 +152,6 @@ function getLineageChildWorktree( return worktree } -function isValidLineageChild( - parent: Worktree | undefined, - child: Worktree | undefined, - lineage: WorktreeLineage, - includedIds: ReadonlySet -): child is Worktree { - if ( - !parent || - !child || - parent.isArchived || - child.isArchived || - !includedIds.has(parent.id) || - includedIds.has(child.id) - ) { - return false - } - return isCurrentLineagePair(parent, child, lineage) -} - -function isCurrentLineagePair( - parent: Worktree | undefined, - child: Worktree, - lineage: WorktreeLineage -): parent is Worktree { - return Boolean( - parent && - !parent.isArchived && - !child.isArchived && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - function sortWorktreesByRecentActivity(left: Worktree, right: Worktree): number { return ( getWorktreeActivityTime(right) - getWorktreeActivityTime(left) || diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index f7de53c9634..f036894f852 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -9,9 +9,11 @@ import { shouldContinueDeleteSiblingPositionRestore, getWorktreeParentPickerAnchor, getWorktreeParentPickerLabel, + hasWorktreeParentLink, isWorktreeParentPickerDisabled, selectMenuScopedMap } from './WorktreeContextMenu' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' describe('selectMenuScopedMap (delete-teardown re-render guard)', () => { // Why: the closed menu wrapper must stay inert to delete teardown's high-churn @@ -139,6 +141,26 @@ describe('shouldContinueDeleteSiblingPositionRestore', () => { }) describe('parent picker context menu affordance', () => { + it('offers unlink for valid inline-only legacy lineage after stable-update hydration', () => { + const parent = { id: 'repo::parent', instanceId: 'parent-instance' } + const lineage: WorktreeLineage = { + worktreeId: 'repo::child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + const child = { + id: lineage.worktreeId, + instanceId: lineage.worktreeInstanceId, + lineage + } as Worktree & { lineage: WorktreeLineage } + + expect(hasWorktreeParentLink(child, {}, {})).toBe(true) + }) + it('uses set/change labels based on valid parent presence', () => { expect(getWorktreeParentPickerLabel(null)).toBe('Set Parent Worktree...') expect(getWorktreeParentPickerLabel('parent-1')).toBe('Change Parent Worktree...') diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 6b1e940e068..5a67e5450ec 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -41,7 +41,11 @@ import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' -import { getLineageRenderInfo } from './worktree-list-groups' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo, + getProjectedWorktreeLineage +} from './worktree-lineage-projection' import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status' import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' @@ -82,6 +86,7 @@ const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} +const EMPTY_CYCLIC_LINEAGE_IDS: ReadonlySet = new Set() // Why: the gating decision for the menu-only store subscriptions. When the menu is // closed we MUST return the same `empty` reference every render so Zustand's Object.is @@ -92,6 +97,17 @@ export function selectMenuScopedMap(menuOpen: boolean, live: T, empty: T): T return menuOpen ? live : empty } +export function hasWorktreeParentLink( + worktree: Worktree, + lineageById: AppState['worktreeLineageById'], + workspaceLineageByChildKey: AppState['workspaceLineageByChildKey'] +): boolean { + return Boolean( + getProjectedWorktreeLineage(worktree, lineageById) || + workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)] + ) +} + function shouldUseNativeContextMenu(target: EventTarget | null): boolean { const maybeElement = target as { closest?: (selector: string) => Element | null @@ -376,29 +392,41 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ isMultiContext && batchDeleteWorktrees.length > 0 ? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}` : 'Delete Selected' - const lineage = worktreeLineageById[worktree.id] - const workspaceLineage = workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)] + const hasParentLink = hasWorktreeParentLink( + worktree, + worktreeLineageById, + workspaceLineageByChildKey + ) + const cyclicLineageIds = useMemo( + () => + menuOpen + ? getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap) + : EMPTY_CYCLIC_LINEAGE_IDS, + [menuOpen, worktreeLineageById, worktreeMap] + ) // Why: path-derived worktree IDs can be reused. The menu must honor the same // instance check as grouped rows before offering navigation to a parent. const lineageInfo = useMemo( - () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap), - [worktree, worktreeLineageById, worktreeMap] + () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap, cyclicLineageIds), + [cyclicLineageIds, worktree, worktreeLineageById, worktreeMap] ) const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null - const hasAnyContextLineage = activeContextWorktrees.some( - (item) => - worktreeLineageById[item.id] || workspaceLineageByChildKey[worktreeWorkspaceKey(item.id)] + const hasAnyContextLineage = activeContextWorktrees.some((item) => + hasWorktreeParentLink(item, worktreeLineageById, workspaceLineageByChildKey) ) const eligibleParentCount = useMemo( () => - getEligibleWorktreeParents({ - child: worktree, - worktrees: allWorktrees, - lineageById: worktreeLineageById, - worktreeMap, - repoMap - }).length, - [allWorktrees, repoMap, worktree, worktreeLineageById, worktreeMap] + menuOpen + ? getEligibleWorktreeParents({ + child: worktree, + worktrees: allWorktrees, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }).length + : 0, + [allWorktrees, cyclicLineageIds, menuOpen, repoMap, worktree, worktreeLineageById, worktreeMap] ) const setMenuOpenState = useCallback( @@ -814,7 +842,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ {getWorktreeParentPickerLabel(validParentWorktreeId)} - {(validParentWorktreeId || lineage || workspaceLineage) && ( + {(validParentWorktreeId || hasParentLink) && ( <> {validParentWorktreeId && ( @@ -825,7 +853,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ )} )} - {(lineage || workspaceLineage) && ( + {hasParentLink && ( {translate( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index fcc71abb98c..90372e7cf1d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -122,6 +122,10 @@ import { setVisibleWorktreeIds, sidebarHasActiveFilters } from './visible-worktrees' +import { + getCyclicProjectedWorktreeLineageIds, + getWorktreeLineageAncestors +} from './worktree-lineage-projection' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' import { @@ -249,7 +253,7 @@ import { suppressNewExternalWorktreeInbox, type NewExternalWorktreesInboxActionState } from './new-external-worktrees-inbox-actions' -import { getEligibleWorktreeParents } from './worktree-parent-candidates' +import { isEligibleWorktreeParent } from './worktree-parent-candidates' import { buildImportedWorktreesCardCandidates, getHiddenImportedWorktrees @@ -1395,6 +1399,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) + const cyclicLineageIds = useMemo( + () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), + [worktreeLineageById, worktreeMap] + ) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -2085,25 +2093,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp toggleGroup(hostGroupKey) } - const seen = new Set() - let current: Worktree | undefined = targetWorktree - while (current && !seen.has(current.id)) { - seen.add(current.id) - const lineage = worktreeLineageById[current.id] - const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined - if ( - !lineage || - !parent || - current.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - break - } + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { const lineageGroupKey = getLineageGroupKey(parent.id) if (collapsedGroups.has(lineageGroupKey)) { toggleGroup(lineageGroupKey) } - current = parent } const groupKeys = @@ -2695,17 +2693,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (!child) { return false } - return getEligibleWorktreeParents({ - child, - worktrees, - lineageById: worktreeLineageById, - worktreeMap, - repoMap - }).some((candidate) => candidate.id === parentId) + const candidateParent = worktreeMap.get(parentId) + return Boolean( + candidateParent && + isEligibleWorktreeParent({ + child, + candidateParent, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }) + ) }) return canAssignAll ? target : { ...target, lineageParentId: null } }, - [repoMap, worktreeLineageById, worktreeMap, worktrees] + [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] ) const commitWorktreeLineageParentDrop = useCallback( @@ -2742,7 +2745,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const ids = getReorderedWorktreeIdsToUnnest({ draggedIds: args.draggedIds, sourceGroupIds: sourceGroup.worktreeIds, - lineageById: worktreeLineageById + lineageById: worktreeLineageById, + worktreeMap, + cyclicLineageIds }) if (ids.length === 0) { return @@ -2760,7 +2765,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } ) }, - [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] ) const flushWorktreePointerDrag = useCallback(() => { @@ -5496,16 +5501,9 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceHostScope, visibleWorkspaceHostIds, defaultHostId: getSettingsFocusedExecutionHostId(settings), - worktreeLineageById + worktreeLineageById, + forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined }) - if ( - agentSendTargetWorktreeId && - !ids.includes(agentSendTargetWorktreeId) && - worktreeMap.has(agentSendTargetWorktreeId) - ) { - // Why: send-target mode is a temporary picker; surface the target card without rewriting the user's filters. - ids.push(agentSendTargetWorktreeId) - } return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) }, [ agentSendTargetWorktreeId, @@ -5573,22 +5571,12 @@ const WorktreeList = React.memo(function WorktreeList({ } } - const seen = new Set() - let current: Worktree | undefined = targetWorktree - while (current && !seen.has(current.id)) { - seen.add(current.id) - const lineage = worktreeLineageById[current.id] - const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined - if ( - !lineage || - !parent || - current.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - break - } + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { next.delete(getLineageGroupKey(parent.id)) - current = parent } return next }, [ diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts index 0b823f62c8c..dc971a0603e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -171,6 +171,109 @@ describe('getFolderWorkspaceCardPrDisplay', () => { expect(display).toMatchObject({ number: 4, status: 'success' }) }) + it('includes a nested PR from exact inline-only legacy lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [parent.id, parent], + [inlineNested.id, inlineNested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toMatchObject({ number: 4, status: 'success' }) + }) + + it('keeps a stale side-map entry authoritative over valid inline lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [nested.id]: { + ...makeWorktreeLineage(nested, parent), + parentWorktreeInstanceId: 'stale-parent' + } + }, + worktreeMap: new Map([ + [parent.id, parent], + [inlineNested.id, inlineNested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + + it.each([ + ['repository', { repoId: 'repo-2' }, {}], + ['known host', { hostId: 'ssh:remote' as const }, { hostId: 'local' as const }], + ['known project', { projectId: 'project-b' }, { projectId: 'project-a' }] + ])('excludes nested PRs across a %s boundary', (_boundary, childOverrides, parentOverrides) => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent', ...parentOverrides }) + const nested = makeWorktree({ + id: 'nested', + instanceId: 'nested', + linkedPR: 4, + ...childOverrides + }) + const nestedRepo = { ...repo, id: nested.repoId } + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([ + [repo.id, repo], + [nestedRepo.id, nestedRepo] + ]), + hostedReviewCache: null, + prCache: { [`${nested.repoId}::nested`]: makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + + it('excludes nested PRs from cyclic projected lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [parent.id]: makeWorktreeLineage(parent, nested), + [nested.id]: makeWorktreeLineage(nested, parent) + }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + it('uses branch-discovered PR cache for unlinked attached worktrees', () => { const worktree = makeWorktree({ id: 'branch-discovered', linkedPR: null }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index bbd67117685..3f71970ec33 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -6,6 +6,7 @@ import { type ParentPrChecksRow } from '@/components/right-sidebar/parent-pr-checks-rows' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' type FolderWorkspaceCardPrDisplayArgs = { folderWorkspaceId: string @@ -77,21 +78,18 @@ function getAttachedWorktreesForFolderWorkspaceCard({ .filter((worktree): worktree is Worktree => worktree !== null) const included = new Map(directChildren.map((worktree) => [worktree.id, worktree])) - let added = true - - while (added) { - added = false - for (const lineage of Object.values(worktreeLineageById ?? {})) { - if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) { - continue - } - const parent = worktreeMap.get(lineage.parentWorktreeId) - const child = worktreeMap.get(lineage.worktreeId) - if (!isCurrentLineagePair(parent, child, lineage)) { + const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + worktreeLineageById ?? {}, + worktreeMap + ) + const queue = [...directChildren] + for (let index = 0; index < queue.length; index += 1) { + for (const child of childrenByParentId.get(queue[index].id) ?? []) { + if (child.isArchived || included.has(child.id)) { continue } included.set(child.id, child) - added = true + queue.push(child) } } @@ -130,21 +128,6 @@ function getWorkspaceLineageChild( return worktree } -function isCurrentLineagePair( - parent: Worktree | undefined, - child: Worktree | undefined, - lineage: WorktreeLineage -): child is Worktree { - return Boolean( - parent && - child && - !parent.isArchived && - !child.isArchived && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number { return getReviewDisplayPriority(left) - getReviewDisplayPriority(right) } diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 6aaa6d44bd4..841c7e35bed 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -61,9 +61,10 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ workspaceHostScope, visibleWorkspaceHostIds, defaultHostId: getSettingsFocusedExecutionHostId(settings), + worktreeLineageById: {}, // Why: the board has no nested lineage presentation. Ancestor injection // would make filtered-out parents appear as ordinary cards. - worktreeLineageById: {} + injectLineageAncestors: false }) ) }, [ diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 939ca46a1e9..0525568ec4b 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -478,6 +478,109 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) + it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const lineage = makeWorktreeLineage(child, parent) + const resolvedChild = { ...child, lineage } + + const result = computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [child.id, parent.id], + visibleOptions({ + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] } + }) + ) + + expect(result).toEqual([parent.id, child.id]) + }) + + it('keeps inline parents out of non-nested board results across parent filters', () => { + const child = makeWorktree('child') + const run = ( + parent: ReturnType, + options: Partial + ): string[] => { + const resolvedChild = { ...child, lineage: makeWorktreeLineage(child, parent) } + return computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [parent.id, child.id], + visibleOptions({ ...options, injectLineageAncestors: false }) + ) + } + + const sleepingParent = makeWorktree('sleeping-parent') + expect( + run(sleepingParent, { + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] } + }) + ).toEqual([child.id]) + + const defaultBranchParent = makeWorktree('default-parent') + defaultBranchParent.isMainWorktree = true + expect(run(defaultBranchParent, { hideDefaultBranchWorkspace: true })).toEqual([child.id]) + + const automationParent = makeWorktree('automation-parent') + automationParent.automationProvenance = { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Review run', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local' + } + expect(run(automationParent, { hideAutomationGeneratedWorkspaces: true })).toEqual([child.id]) + }) + + it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const lineage = makeWorktreeLineage(child, parent) + const resolvedChild = { ...child, lineage } + + const result = computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [parent.id, child.id], + visibleOptions({ + showSleepingWorkspaces: false, + forcedVisibleWorktreeIds: [child.id] + }) + ) + + expect(result).toEqual([parent.id, child.id]) + }) + + it('keeps the hydrated side-map authoritative over disagreeing inline lineage', () => { + const inlineParent = makeWorktree('inline-parent') + const hydratedParent = makeWorktree('hydrated-parent') + const child = makeWorktree('child') + const inlineLineage = makeWorktreeLineage(child, inlineParent) + const hydratedLineage = makeWorktreeLineage(child, hydratedParent) + const resolvedChild = { ...child, lineage: inlineLineage } + + const result = computeVisibleWorktreeIds( + { repo1: [inlineParent, hydratedParent, resolvedChild] }, + [child.id, inlineParent.id, hydratedParent.id], + visibleOptions({ + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] }, + worktreeLineageById: { [child.id]: hydratedLineage } + }) + ) + + expect(result).toEqual([hydratedParent.id, child.id]) + }) + it('does not resurrect stale lineage parents', () => { const parent = makeWorktree('parent') const child = makeWorktree('child') @@ -537,7 +640,7 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) - it('includes cross-repo parents when repo filtering leaves their valid child visible', () => { + it('does not include a cross-repo parent when repo filtering leaves the child visible', () => { const parent = makeWorktree('parent', 'repo1') const child = makeWorktree('child', 'repo2') const lineage = makeWorktreeLineage(child, parent) @@ -551,7 +654,44 @@ describe('computeVisibleWorktreeIds', () => { }) ) - expect(result).toEqual([parent.id, child.id]) + expect(result).toEqual([child.id]) + }) + + it('does not include a known cross-host parent after host filtering', () => { + const parent = Object.assign(makeWorktree('parent'), { hostId: 'ssh:remote' as const }) + const child = Object.assign(makeWorktree('child'), { hostId: 'local' as const }) + const lineage = makeWorktreeLineage(child, parent) + + const result = computeVisibleWorktreeIds( + { repo1: [parent, child] }, + [child.id, parent.id], + visibleOptions({ + visibleWorkspaceHostIds: ['local'], + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(result).toEqual([child.id]) + }) + + it('does not include a known cross-project parent hidden by another filter', () => { + const parent = Object.assign(makeWorktree('parent'), { + projectId: 'project-b', + isMainWorktree: true + }) + const child = Object.assign(makeWorktree('child'), { projectId: 'project-a' }) + const lineage = makeWorktreeLineage(child, parent) + + const result = computeVisibleWorktreeIds( + { repo1: [parent, child] }, + [child.id, parent.id], + visibleOptions({ + hideDefaultBranchWorkspace: true, + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(result).toEqual([child.id]) }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 8809e7f7835..4287c5caf3a 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -11,6 +11,10 @@ import { type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' /** * Whether a worktree represents the repo's default-branch row that the @@ -124,6 +128,8 @@ export function computeVisibleWorktreeIds( visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null defaultHostId: ExecutionHostId worktreeLineageById: Record + injectLineageAncestors?: boolean + forcedVisibleWorktreeIds?: readonly string[] } ): string[] { let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo }) @@ -177,6 +183,17 @@ export function computeVisibleWorktreeIds( ) } + if (opts.forcedVisibleWorktreeIds && opts.forcedVisibleWorktreeIds.length > 0) { + const includedIds = new Set(all.map((worktree) => worktree.id)) + for (const worktreeId of opts.forcedVisibleWorktreeIds) { + const worktree = lineageAncestorById.get(worktreeId) + if (worktree && !includedIds.has(worktreeId)) { + includedIds.add(worktreeId) + all.push(worktree) + } + } + } + // Apply cached sort order. Items not yet in the cache (e.g. brand-new // worktrees before the next sortEpoch bump) are appended at the end. const orderIndex = new Map(sortedIds.map((id, i) => [id, i])) @@ -186,11 +203,10 @@ export function computeVisibleWorktreeIds( return ai - bi }) - return addVisibleLineageAncestors( - all.map((w) => w.id), - lineageAncestorById, - opts.worktreeLineageById - ) + const visibleIds = all.map((w) => w.id) + return opts.injectLineageAncestors === false + ? visibleIds + : addVisibleLineageAncestors(visibleIds, lineageAncestorById, opts.worktreeLineageById) } function addVisibleLineageAncestors( @@ -201,6 +217,7 @@ function addVisibleLineageAncestors( const result: string[] = [] const included = new Set() const visiting = new Set() + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeById) const addWithAncestors = (id: string): void => { if (included.has(id) || visiting.has(id)) { @@ -211,16 +228,11 @@ function addVisibleLineageAncestors( return } visiting.add(id) - const lineage = lineageById[id] - const parent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined - if ( - parent && - worktree.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) { + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeById, cyclicLineageIds) + if (lineage.state === 'valid') { // Why: sidebar lineage is structural. If a filtered child is visible, // its valid parent must be rendered too so the hierarchy remains legible. - addWithAncestors(parent.id) + addWithAncestors(lineage.parent.id) } visiting.delete(id) if (!included.has(id)) { diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts index 888b82cc845..b5679409cc5 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import type { Worktree, WorktreeLineage } from '../../../../shared/types' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' @@ -70,4 +71,68 @@ describe('getWorkspaceDeleteLineage', () => { expect(lineage.descendants).toEqual([]) expect(lineage.deleteAllTargets).toEqual([parent]) }) + + it('orders an exact inline-only legacy descendant before its parent', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + + const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {}) + + expect(lineage.descendants.map((worktree) => worktree.id)).toEqual([child.id]) + expect(lineage.deleteAllTargets.map((worktree) => worktree.id)).toEqual([child.id, parent.id]) + }) + + it('keeps a stale side-map child authoritative over valid inline lineage', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + + const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], { + [child.id]: { + ...makeLineage(child, parent), + parentWorktreeInstanceId: 'stale-parent-instance' + } + }) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) + + it('rejects cross-repo, cross-host, and cross-project descendants', () => { + const parent: Worktree = { + ...makeWorktree('parent', '/workspaces/parent'), + hostId: LOCAL_EXECUTION_HOST_ID, + projectId: 'project-1' + } + const children: Worktree[] = [ + { ...makeWorktree('repo-child', '/workspaces/repo-child'), repoId: 'repo-2' }, + { + ...makeWorktree('host-child', '/workspaces/host-child'), + hostId: toSshExecutionHostId('other') + }, + { ...makeWorktree('project-child', '/workspaces/project-child'), projectId: 'project-2' } + ] + const lineageById = Object.fromEntries( + children.map((child) => [child.id, makeLineage(child, parent)]) + ) + + const lineage = getWorkspaceDeleteLineage(parent, [parent, ...children], lineageById) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) + + it('does not traverse cyclic projected lineage', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + + const lineage = getWorkspaceDeleteLineage(parent, [parent, child], { + [parent.id]: makeLineage(parent, child), + [child.id]: makeLineage(child, parent) + }) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts index a65748e6f32..ca6c78c1b67 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts @@ -1,41 +1,21 @@ import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' type WorkspaceDeleteLineage = { descendants: Worktree[] deleteAllTargets: Worktree[] } -function isValidLineageLink( - child: Worktree, - parent: Worktree | undefined, - lineage: WorktreeLineage | undefined -): parent is Worktree { - return Boolean( - lineage && - parent && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - export function getWorkspaceDeleteLineage( parent: Worktree, worktrees: readonly Worktree[], lineageById: Record ): WorkspaceDeleteLineage { const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) - const childrenByParentId = new Map() - - for (const worktree of worktrees) { - const lineage = lineageById[worktree.id] - const lineageParent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined - if (!isValidLineageLink(worktree, lineageParent, lineage)) { - continue - } - const children = childrenByParentId.get(lineageParent.id) ?? [] - children.push(worktree) - childrenByParentId.set(lineageParent.id, children) - } + const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + lineageById, + worktreeById + ) const descendants: Worktree[] = [] const childFirstTargets: Worktree[] = [] diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index 0f70912df1c..9241dd2cf11 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' import { getReorderedWorktreeIdsToUnnest, getWorktreeLineageDropTargetId, @@ -45,32 +47,121 @@ describe('getWorktreeLineageDropTargetId', () => { describe('getReorderedWorktreeIdsToUnnest', () => { it('clears parents only for directly dragged nested cards', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const root = makeWorktree('root') + const grandchild = makeWorktree('grandchild') + const lineageById = { + [child.id]: makeLineage(child, parent), + [grandchild.id]: makeLineage(grandchild, child) + } + const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item])) + expect( getReorderedWorktreeIdsToUnnest({ draggedIds: ['child', 'child', 'root', 'grandchild'], sourceGroupIds: ['child', 'root', 'grandchild'], - lineageById: { - child: true, - grandchild: true - } + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) }) ).toEqual(['child', 'grandchild']) }) it('does not clear selected nested cards outside the reordered source group', () => { + const parent = makeWorktree('parent') + const sourceChild = makeWorktree('source-child') + const otherChild = makeWorktree('other-child') + const lineageById = { + [sourceChild.id]: makeLineage(sourceChild, parent), + [otherChild.id]: makeLineage(otherChild, parent) + } + const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item])) + expect( getReorderedWorktreeIdsToUnnest({ draggedIds: ['source-child', 'other-child'], sourceGroupIds: ['source-child'], - lineageById: { - 'source-child': true, - 'other-child': true - } + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) }) ).toEqual(['source-child']) }) + + it('clears an exact inline-only legacy parent', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) + + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: [child.id], + sourceGroupIds: [child.id], + lineageById: {}, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap) + }) + ).toEqual([child.id]) + }) + + it('does not fall back to inline lineage when the side-map has a stale child entry', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + const lineageById = { + [child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' } + } + const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) + + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: [child.id], + sourceGroupIds: [child.id], + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + }) + ).toEqual([]) + }) }) +function makeWorktree(id: string): Worktree { + return { + id, + instanceId: `${id}-instance`, + repoId: 'repo-1', + path: `/worktrees/${id}`, + head: 'abc123', + branch: id, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } +} + +function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + function makeTarget(args: { worktreeId: string top: number diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index dc011d33a77..f171ce5d2a6 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -1,3 +1,6 @@ +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getLineageRenderInfo } from './worktree-lineage-projection' + const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' @@ -52,13 +55,22 @@ export function getWorktreeLineageDropTargetId(args: { export function getReorderedWorktreeIdsToUnnest(args: { draggedIds: readonly string[] sourceGroupIds: readonly string[] - lineageById: Readonly> + lineageById: Readonly> + worktreeMap: ReadonlyMap + cyclicLineageIds: ReadonlySet }): string[] { const ids: string[] = [] const seen = new Set() const sourceGroupIdSet = new Set(args.sourceGroupIds) for (const id of args.draggedIds) { - if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + const worktree = args.worktreeMap.get(id) + if ( + seen.has(id) || + !sourceGroupIdSet.has(id) || + !worktree || + getLineageRenderInfo(worktree, args.lineageById, args.worktreeMap, args.cyclicLineageIds) + .state !== 'valid' + ) { continue } seen.add(id) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts new file mode 100644 index 00000000000..38c8584c064 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -0,0 +1,100 @@ +import { + getCyclicWorktreeLineageChildIds, + isValidResolvedWorktreeLineageEdge +} from '../../../../shared/resolved-worktree-lineage' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' + +export type LineageRenderInfo = + | { state: 'none' } + | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } + | { state: 'missing'; lineage: WorktreeLineage } + +type WorktreeWithResolvedLineage = Worktree & { lineage?: WorktreeLineage | null } + +export function getProjectedWorktreeLineage( + worktree: Worktree, + lineageById: Readonly> +): WorktreeLineage | null | undefined { + if (Object.prototype.hasOwnProperty.call(lineageById, worktree.id)) { + return lineageById[worktree.id] + } + return (worktree as WorktreeWithResolvedLineage).lineage +} + +export function getCyclicProjectedWorktreeLineageIds( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Set { + const validLineageByChildId = new Map() + for (const worktree of worktreeMap.values()) { + const lineage = getProjectedWorktreeLineage(worktree, lineageById) + if (!lineage) { + continue + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + if (parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)) { + validLineageByChildId.set(worktree.id, lineage) + } + } + return getCyclicWorktreeLineageChildIds(validLineageByChildId) +} + +export function getLineageRenderInfo( + worktree: Worktree, + lineageById: Readonly>, + worktreeMap: ReadonlyMap, + cyclicLineageIds: ReadonlySet +): LineageRenderInfo { + const lineage = getProjectedWorktreeLineage(worktree, lineageById) + if (!lineage) { + return { state: 'none' } + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + if ( + cyclicLineageIds.has(worktree.id) || + !parent || + !isValidResolvedWorktreeLineageEdge(worktree, parent, lineage) + ) { + return { state: 'missing', lineage } + } + return { state: 'valid', lineage, parent } +} + +export function getProjectedWorktreeLineageChildrenByParentId( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Map { + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const childrenByParentId = new Map() + for (const worktree of worktreeMap.values()) { + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds) + if (lineage.state !== 'valid') { + continue + } + const children = childrenByParentId.get(lineage.parent.id) ?? [] + children.push(worktree) + childrenByParentId.set(lineage.parent.id, children) + } + return childrenByParentId +} + +export function getWorktreeLineageAncestors( + worktree: Worktree, + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Worktree[] { + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const ancestors: Worktree[] = [] + const seen = new Set() + let current: Worktree | undefined = worktree + while (current && !seen.has(current.id)) { + seen.add(current.id) + const lineage = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds) + if (lineage.state !== 'valid') { + break + } + ancestors.push(lineage.parent) + current = lineage.parent + } + return ancestors +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 6261443c3e6..c3405400dd2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -19,6 +19,7 @@ import { REPO_HEADER_ACTION_BUTTON_CLASS, REPO_HEADER_ACTION_REVEAL_CLASS } from './repo-header-action-button-class' +import { getWorktreeLineageAncestors } from './worktree-lineage-projection' import type { DetectedWorktree, Project, @@ -3309,6 +3310,12 @@ describe('project groups', () => { }) describe('buildRows workspace lineage nesting', () => { + type ResolvedLineageWorktree = Worktree & { + lineage: WorktreeLineage | null + workspaceLineage?: null + parentWorktreeId?: string | null + } + const parent: Worktree = { ...worktree, id: 'wt-parent', @@ -3399,6 +3406,255 @@ describe('buildRows workspace lineage nesting', () => { }) }) + it('nests stable-update resolved legacy lineage when generalized lineage is absent', () => { + const parentId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/assigned-issues' + const childId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9276-nested-ssh-runtime-routing' + const secondChildId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9744-terminal-close-lifecycle' + const resolvedParent: ResolvedLineageWorktree = { + ...parent, + id: parentId, + instanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', + lineage: null, + workspaceLineage: null + } + const resolvedLineage: WorktreeLineage = { + ...lineage, + worktreeId: childId, + worktreeInstanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + const resolvedChild: ResolvedLineageWorktree = { + ...child, + id: childId, + instanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', + lineage: resolvedLineage, + workspaceLineage: null + } + const secondResolvedLineage: WorktreeLineage = { + ...resolvedLineage, + worktreeId: secondChildId, + worktreeInstanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417' + } + const secondResolvedChild: ResolvedLineageWorktree = { + ...child, + id: secondChildId, + instanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417', + lineage: secondResolvedLineage, + workspaceLineage: null + } + + const rows = buildRows( + 'none', + [secondResolvedChild, resolvedChild, resolvedParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [resolvedParent.id, resolvedParent], + [resolvedChild.id, resolvedChild], + [secondResolvedChild.id, secondResolvedChild] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items.map((row) => [row.worktree.id, row.depth])).toEqual([ + [parentId, 0], + [secondChildId, 1], + [childId, 1] + ]) + expect(items[0]).toMatchObject({ lineageChildCount: 2, lineageCollapsed: false }) + }) + + it('rejects stale resolved lineage after a parent instance is replaced', () => { + const resolvedChild: ResolvedLineageWorktree = { + ...child, + lineage: { ...lineage, parentWorktreeInstanceId: 'replaced-parent-instance' } + } + const rows = buildRows( + 'none', + [resolvedChild, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [parent.id, parent], + [resolvedChild.id, resolvedChild] + ]), + true + ) + + expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0]) + }) + + it('keeps mixed cyclic lineage participants visible as roots', () => { + const parentLineage: WorktreeLineage = { + ...lineage, + worktreeId: parent.id, + worktreeInstanceId: parent.instanceId!, + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + } + const rows = buildRows( + 'none', + [grandchild, child, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage, [parent.id]: parentLineage }, + new Map([ + [parent.id, parent], + [child.id, child], + [grandchild.id, grandchild] + ]), + true + ) + + expect( + rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) + ).toEqual([ + [grandchild.id, 0], + [child.id, 0], + [parent.id, 0] + ]) + }) + + it('resolves inline-only ancestor chains for reveal and temporary picker expansion', () => { + const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } + const resolvedGrandchild: ResolvedLineageWorktree = { + ...grandchild, + lineage: grandchildLineage + } + const worktreeMap = new Map([ + [parent.id, parent], + [resolvedChild.id, resolvedChild], + [resolvedGrandchild.id, resolvedGrandchild] + ]) + + expect( + getWorktreeLineageAncestors(resolvedGrandchild, {}, worktreeMap).map( + (worktree) => worktree.id + ) + ).toEqual([child.id, parent.id]) + }) + + it('keeps a resolved child at the root when its parent is missing', () => { + const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } + const rows = buildRows( + 'none', + [resolvedChild], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[child.id, resolvedChild]]), + true + ) + + expect(rows.find((row) => row.type === 'item')).toMatchObject({ depth: 0 }) + }) + + it.each([ + ['repo', { repoId: 'other-repo' }], + ['host', { hostId: 'ssh:other-host' as const }], + ['project', { projectId: 'github:other/project' }] + ])('does not nest resolved lineage across a known %s boundary', (_label, boundary) => { + const boundedParent = { + ...parent, + repoId: 'repo-1', + hostId: 'local' as const, + projectId: 'github:stablyai/orca', + ...boundary + } + const boundedChild: ResolvedLineageWorktree = { + ...child, + repoId: 'repo-1', + hostId: 'local' as const, + projectId: 'github:stablyai/orca', + lineage + } + const rows = buildRows( + 'none', + [boundedChild, boundedParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [boundedParent.id, boundedParent], + [boundedChild.id, boundedChild] + ]), + true + ) + + expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0]) + }) + + it('keeps the hydrated lineage side-map authoritative when inline metadata disagrees', () => { + const otherParent = { + ...parent, + id: 'wt-other-parent', + instanceId: 'other-parent-instance' + } + const hydratedLineage = { + ...lineage, + parentWorktreeId: otherParent.id, + parentWorktreeInstanceId: otherParent.instanceId! + } + const resolvedChild: ResolvedLineageWorktree = { + ...child, + parentWorktreeId: parent.id, + lineage + } + const rows = buildRows( + 'none', + [resolvedChild, parent, otherParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: hydratedLineage }, + new Map([ + [parent.id, parent], + [otherParent.id, otherParent], + [child.id, resolvedChild] + ]), + true + ) + + expect( + rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) + ).toEqual([ + [parent.id, 0], + [otherParent.id, 0], + [child.id, 1] + ]) + }) + it('supports nested lineage chains beyond one level', () => { const rows = buildRows( 'none', @@ -3504,7 +3760,8 @@ describe('buildRows workspace lineage nesting', () => { new Map([ [parent.id, parent], [child.id, child] - ]) + ]), + new Set() ) expect(info).toMatchObject({ state: 'missing' }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 41fcdaf9c3b..cc637d61865 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -43,6 +43,12 @@ import { } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' + +export { getLineageRenderInfo } from './worktree-lineage-projection' export { branchName } @@ -373,30 +379,6 @@ export function getLineageGroupKey(worktreeId: string): string { return `${LINEAGE_GROUP_PREFIX}${worktreeId}` } -export type LineageRenderInfo = - | { state: 'none' } - | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } - | { state: 'missing'; lineage: WorktreeLineage } - -export function getLineageRenderInfo( - worktree: Worktree, - lineageById: Record, - worktreeMap: Map -): LineageRenderInfo { - const lineage = lineageById[worktree.id] - if (!lineage) { - return { state: 'none' } - } - const parent = worktreeMap.get(lineage.parentWorktreeId) - if ( - !parent || - worktree.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - return { state: 'missing', lineage } - } - return { state: 'valid', lineage, parent } -} export function getPRGroupKey( worktree: Worktree, repoMap: Map, @@ -603,9 +585,17 @@ function appendWorktreeRows( groupDepth: number sectionKey: string hostContextLabelByRepoId?: ReadonlyMap + cyclicLineageIds: ReadonlySet } ): void { - const { nestLineage, collapsedGroups, groupDepth, sectionKey, hostContextLabelByRepoId } = options + const { + nestLineage, + collapsedGroups, + groupDepth, + sectionKey, + hostContextLabelByRepoId, + cyclicLineageIds + } = options if (!nestLineage) { for (const worktree of worktrees) { result.push( @@ -629,7 +619,7 @@ function appendWorktreeRows( const childrenByParentId = new Map() const childIds = new Set() for (const worktree of worktrees) { - const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap) + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds) if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) { continue } @@ -999,6 +989,9 @@ export function buildRows( ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) + const cyclicLineageIds = nestLineage + ? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + : new Set() const pendingByRepo = new Map() for (const creation of pendingCreations) { @@ -1058,7 +1051,8 @@ export function buildRows( nestLineage, collapsedGroups, groupDepth: 0, - sectionKey: ALL_GROUP_KEY + sectionKey: ALL_GROUP_KEY, + cyclicLineageIds }) } } @@ -1302,7 +1296,8 @@ export function buildRows( collapsedGroups, groupDepth: projectGroupDepth, sectionKey: key, - hostContextLabelByRepoId + hostContextLabelByRepoId, + cyclicLineageIds }) } else { appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { @@ -1310,7 +1305,8 @@ export function buildRows( collapsedGroups, groupDepth: projectGroupDepth, sectionKey: key, - hostContextLabelByRepoId + hostContextLabelByRepoId, + cyclicLineageIds }) } } diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 6509743ba09..981ea7e3a7d 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,6 +1,7 @@ import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' +import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' type ParentCandidateArgs = { child: Worktree @@ -8,6 +9,7 @@ type ParentCandidateArgs = { lineageById: Record worktreeMap: Map repoMap: Map> + cyclicLineageIds?: ReadonlySet } function getWorktreeOwnerHostId( @@ -23,20 +25,51 @@ export function getEligibleWorktreeParents({ worktrees, lineageById, worktreeMap, - repoMap + repoMap, + cyclicLineageIds: precomputedCyclicLineageIds }: ParentCandidateArgs): Worktree[] { const childHostId = getWorktreeOwnerHostId(child, repoMap) - return worktrees.filter( - (candidate) => - candidate.repoId === child.repoId && - childHostId !== null && - getWorktreeOwnerHostId(candidate, repoMap) === childHostId && - !candidate.isArchived && - canAssignWorktreeParent({ - child, - candidateParent: candidate, - lineageById, - worktreeMap - }) + const cyclicLineageIds = + precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + return worktrees.filter((candidate) => + isEligibleWorktreeParent({ + child, + candidateParent: candidate, + lineageById, + worktreeMap, + repoMap, + cyclicLineageIds, + childHostId + }) + ) +} + +export function isEligibleWorktreeParent({ + child, + candidateParent, + lineageById, + worktreeMap, + repoMap, + cyclicLineageIds, + childHostId = getWorktreeOwnerHostId(child, repoMap) +}: Omit & { + candidateParent: Worktree + childHostId?: string | null +}): boolean { + return ( + candidateParent.repoId === child.repoId && + childHostId !== null && + getWorktreeOwnerHostId(candidateParent, repoMap) === childHostId && + (child.projectId === undefined || + candidateParent.projectId === undefined || + child.projectId === candidateParent.projectId) && + !candidateParent.isArchived && + canAssignWorktreeParent({ + child, + candidateParent, + lineageById, + worktreeMap, + cyclicLineageIds + }) ) } diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts index 12339cf61be..7dfe0360f02 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' -import { getEligibleWorktreeParents } from './worktree-parent-candidates' +import { getEligibleWorktreeParents, isEligibleWorktreeParent } from './worktree-parent-candidates' function makeWorktree(id: string, repoId = 'repo'): Worktree { return { @@ -209,6 +209,34 @@ describe('canAssignWorktreeParent', () => { ).toEqual([sameHost.id]) }) + it('excludes a candidate across a known project boundary for picker and direct drop checks', () => { + const child = { ...makeWorktree('child'), projectId: 'project-a' } + const sameProject = { ...makeWorktree('same-project'), projectId: 'project-a' } + const otherProject = { ...makeWorktree('other-project'), projectId: 'project-b' } + const worktrees = [child, sameProject, otherProject] + const worktreeMap = makeMap(worktrees) + const repoMap = makeRepoMap() + + expect( + getEligibleWorktreeParents({ + child, + worktrees, + lineageById: {}, + worktreeMap, + repoMap + }).map((worktree) => worktree.id) + ).toEqual([sameProject.id]) + expect( + isEligibleWorktreeParent({ + child, + candidateParent: otherProject, + lineageById: {}, + worktreeMap, + repoMap + }) + ).toBe(false) + }) + it('excludes archived worktrees from picker candidates', () => { const child = makeWorktree('child') const archived = makeWorktree('archived') diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts index 07575aee9fd..fe2c4261064 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts @@ -1,24 +1,31 @@ import type { Worktree, WorktreeLineage } from '../../../../shared/types' -import { getLineageRenderInfo } from './worktree-list-groups' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' type ParentEligibilityArgs = { child: Worktree candidateParent: Worktree lineageById: Record worktreeMap: Map + cyclicLineageIds?: ReadonlySet } export function canAssignWorktreeParent({ child, candidateParent, lineageById, - worktreeMap + worktreeMap, + cyclicLineageIds: precomputedCyclicLineageIds }: ParentEligibilityArgs): boolean { if (child.id === candidateParent.id) { return false } - const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap) + const cyclicLineageIds = + precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap, cyclicLineageIds) if (childLineage.state === 'valid' && childLineage.parent.id === candidateParent.id) { return false } @@ -26,14 +33,14 @@ export function canAssignWorktreeParent({ let current: Worktree | undefined = candidateParent const visited = new Set() while (current) { - if (visited.has(current.id)) { + if (visited.has(current.id) || cyclicLineageIds.has(current.id)) { return false } visited.add(current.id) if (current.id === child.id) { return false } - const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap) + const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds) // Why: stale instance links are broken edges for renderer filtering; the // backend remains the authoritative final cycle guard. current = lineageInfo.state === 'valid' ? lineageInfo.parent : undefined diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 2bc6fa59b7d..57be252a49f 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1668,6 +1668,42 @@ describe('worktree lineage state', () => { expect(store.getState().sortEpoch).toBe(4) }) + it('clears inline local lineage immediately when an inline-only child is unnested', async () => { + const store = createTestStore() + const lineage = makeLineage() + const parent = { + ...makeWorktree({ + id: lineage.parentWorktreeId, + instanceId: lineage.parentWorktreeInstanceId, + repoId: 'repo1' + }), + childWorktreeIds: [lineage.worktreeId], + lineage: null + } + const child = { + ...makeWorktree({ + id: lineage.worktreeId, + instanceId: lineage.worktreeInstanceId, + repoId: 'repo1' + }), + parentWorktreeId: lineage.parentWorktreeId, + childWorktreeIds: [], + lineage + } + mockApi.worktrees.updateLineage.mockResolvedValue(null) + store.setState({ + worktreesByRepo: { repo1: [parent, child] }, + worktreeLineageById: {} + } as Partial) + + await store.getState().updateWorktreeLineage(child.id, { noParent: true }) + + expect(store.getState().worktreesByRepo.repo1).toMatchObject([ + { id: parent.id, childWorktreeIds: [] }, + { id: child.id, parentWorktreeId: null, lineage: null } + ]) + }) + it('syncs workspace lineage when a child is manually reparented', async () => { const store = createTestStore() const lineage = makeLineage({ @@ -5680,6 +5716,64 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { addedAt: 0 } + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { + const store = createTestStore() + const parent = makeWorktree({ + id: 'repoA::/a/parent', + instanceId: 'parent-instance', + repoId: 'repoA', + path: '/a/parent' + }) + const child = makeWorktree({ + id: 'repoA::/a/child', + instanceId: 'child-instance', + repoId: 'repoA', + path: '/a/child' + }) + const lineage = makeLineage({ + worktreeId: child.id, + worktreeInstanceId: child.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId! + }) + const resolvedParent = { + ...parent, + parentWorktreeId: null, + childWorktreeIds: [child.id], + lineage: null, + workspaceLineage: null + } + const resolvedChild = { + ...child, + parentWorktreeId: parent.id, + childWorktreeIds: [], + lineage, + workspaceLineage: null + } + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repoA', [resolvedParent, resolvedChild]) + ) + store.setState({ + repos: [repoA], + hasHydratedWorktreePurge: true, + worktreeLineageById: {} + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreeLineageById).toEqual({}) + expect(store.getState().worktreesByRepo.repoA).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: child.id, + parentWorktreeId: parent.id, + lineage, + workspaceLineage: null + }) + ]) + ) + }) + it('defers the purge when a sibling repo fetch fails (F1 regression)', async () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 31b3542e1ae..689fd546282 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1062,6 +1062,51 @@ async function setWorktreeLineageForRuntime( } } +function projectLocalWorktreeLineageUpdate( + worktreesByRepo: Record, + worktreeId: string, + lineage: WorktreeLineage | null +): Record { + let nextByRepo = worktreesByRepo + for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) { + let repoChanged = false + const projected = worktrees.map((worktree) => { + const current = worktree as WorktreeWithLineage + const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false + const isParent = + lineage?.parentWorktreeId === worktree.id && + lineage.parentWorktreeInstanceId === worktree.instanceId + let childWorktreeIds = current.childWorktreeIds + if (hadChild) { + childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId) + } + if (isParent && !childWorktreeIds?.includes(worktreeId)) { + childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId] + } + if (worktree.id === worktreeId) { + repoChanged = true + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + lineage + } + } + if (hadChild || isParent) { + repoChanged = true + return { ...worktree, childWorktreeIds } + } + return worktree + }) + if (repoChanged) { + if (nextByRepo === worktreesByRepo) { + nextByRepo = { ...worktreesByRepo } + } + nextByRepo[repoId] = projected + } + } + return nextByRepo +} + function applyWorktreeLineageUpdate( set: Parameters>[0], worktreeId: string, @@ -1074,6 +1119,18 @@ function applyWorktreeLineageUpdate( } else { delete next[worktreeId] } + const worktreesByRepo = + result.target.kind === 'local' + ? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage) + : result.updatedRemoteWorktree + ? replaceWorktreeInRepoLists( + s.worktreesByRepo, + withRepoHostOwnership( + result.updatedRemoteWorktree, + repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) + ) + ) + : s.worktreesByRepo return { worktreeLineageById: next, workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage( @@ -1081,16 +1138,7 @@ function applyWorktreeLineageUpdate( result.lineage, s.workspaceLineageByChildKey ), - worktreesByRepo: - result.target.kind === 'local' || !result.updatedRemoteWorktree - ? s.worktreesByRepo - : replaceWorktreeInRepoLists( - s.worktreesByRepo, - withRepoHostOwnership( - result.updatedRemoteWorktree, - repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) - ) - ), + worktreesByRepo, sortEpoch: s.sortEpoch + 1 } }) diff --git a/src/shared/resolved-worktree-lineage.test.ts b/src/shared/resolved-worktree-lineage.test.ts new file mode 100644 index 00000000000..fc42c1ed608 --- /dev/null +++ b/src/shared/resolved-worktree-lineage.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import type { Worktree, WorktreeLineage } from './types' +import { projectResolvedWorktreeLineage } from './resolved-worktree-lineage' + +function worktree(id: string, instanceId: string, overrides: Partial = {}): Worktree { + return { + id, + instanceId, + repoId: 'repo', + path: join('workspace', id), + head: 'abc123', + branch: `refs/heads/${id}`, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function lineage(overrides: Partial = {}): WorktreeLineage { + return { + worktreeId: 'child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1, + ...overrides + } +} + +describe('projectResolvedWorktreeLineage', () => { + const parent = worktree('parent', 'parent-instance') + const child = worktree('child', 'child-instance') + + it('projects exact instance-aware parent and child metadata', () => { + const projected = projectResolvedWorktreeLineage([child, parent], { child: lineage() }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [], lineage: lineage() }, + { id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'], lineage: null } + ]) + }) + + it.each([ + ['stale child instance', lineage({ worktreeInstanceId: 'old-child' })], + ['stale parent instance', lineage({ parentWorktreeInstanceId: 'old-parent' })], + ['mismatched child record', lineage({ worktreeId: 'other-child' })] + ])('rejects %s', (_label, candidate) => { + const projected = projectResolvedWorktreeLineage([child, parent], { child: candidate }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, lineage: null }, + { id: 'parent', childWorktreeIds: [] } + ]) + }) + + it.each([ + ['repo', { repoId: 'other-repo' }, {}], + ['known host', { hostId: 'local' as const }, { hostId: 'ssh:remote' as const }], + ['known project', { projectId: 'github:stablyai/orca' }, { projectId: 'github:other/project' }] + ])('rejects a %s boundary mismatch', (_label, childOverrides, parentOverrides) => { + const boundedChild = worktree('child', 'child-instance', childOverrides) + const boundedParent = worktree('parent', 'parent-instance', parentOverrides) + + const projected = projectResolvedWorktreeLineage([boundedChild, boundedParent], { + child: lineage() + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, lineage: null }, + { id: 'parent', childWorktreeIds: [] } + ]) + }) + + it('accepts legacy records when only one side has host or project identity', () => { + const legacyChild = worktree('child', 'child-instance', { + hostId: 'local', + projectId: 'github:stablyai/orca' + }) + + const projected = projectResolvedWorktreeLineage([legacyChild, parent], { + child: lineage() + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', lineage: lineage() }, + { id: 'parent', childWorktreeIds: ['child'] } + ]) + }) + + it('rejects self-parent lineage', () => { + const projected = projectResolvedWorktreeLineage([child], { + child: lineage({ + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + }) + }) + + expect(projected[0]).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + }) + }) + + it('rejects every edge in a multi-node cycle without hiding valid descendants', () => { + const grandchild = worktree('grandchild', 'grandchild-instance') + const parentToChild = lineage({ + worktreeId: parent.id, + worktreeInstanceId: parent.instanceId!, + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + }) + const grandchildToParent = lineage({ + worktreeId: grandchild.id, + worktreeInstanceId: grandchild.instanceId! + }) + + const projected = projectResolvedWorktreeLineage([child, parent, grandchild], { + child: lineage(), + parent: parentToChild, + grandchild: grandchildToParent + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, childWorktreeIds: [], lineage: null }, + { + id: 'parent', + parentWorktreeId: null, + childWorktreeIds: ['grandchild'], + lineage: null + }, + { id: 'grandchild', parentWorktreeId: 'parent', lineage: grandchildToParent } + ]) + }) + + it('rejects a missing parent without mutating the raw lineage record', () => { + const rawLineage = lineage() + const projected = projectResolvedWorktreeLineage([child], { child: rawLineage }) + + expect(projected[0]).toMatchObject({ parentWorktreeId: null, lineage: null }) + expect(rawLineage.parentWorktreeId).toBe('parent') + }) + + it('replaces disagreeing parent and child projections from the validated lineage record', () => { + const projected = projectResolvedWorktreeLineage( + [ + { ...child, parentWorktreeId: 'stale-parent', childWorktreeIds: ['stale-child'] }, + { ...parent, parentWorktreeId: 'stale-parent', childWorktreeIds: [] } + ] as (Worktree & { parentWorktreeId: string; childWorktreeIds: string[] })[], + { child: lineage() } + ) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [] }, + { id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'] } + ]) + }) +}) diff --git a/src/shared/resolved-worktree-lineage.ts b/src/shared/resolved-worktree-lineage.ts new file mode 100644 index 00000000000..4967d8450b4 --- /dev/null +++ b/src/shared/resolved-worktree-lineage.ts @@ -0,0 +1,108 @@ +import type { Worktree, WorktreeLineage } from './types' + +export type WorktreeWithResolvedLineage = T & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null +} + +export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean { + return ( + child.repoId === parent.repoId && + (child.hostId === undefined || parent.hostId === undefined || child.hostId === parent.hostId) && + (child.projectId === undefined || + parent.projectId === undefined || + child.projectId === parent.projectId) + ) +} + +export function isValidResolvedWorktreeLineageEdge( + child: Worktree, + parent: Worktree, + lineage: WorktreeLineage +): boolean { + return ( + child.id !== parent.id && + lineage.worktreeId === child.id && + lineage.parentWorktreeId === parent.id && + sharesResolvedWorktreeLineageBoundary(child, parent) && + child.instanceId === lineage.worktreeInstanceId && + parent.instanceId === lineage.parentWorktreeInstanceId + ) +} + +export function getCyclicWorktreeLineageChildIds( + lineageByChildId: ReadonlyMap +): Set { + const processed = new Set() + const cyclic = new Set() + + for (const childId of lineageByChildId.keys()) { + if (processed.has(childId)) { + continue + } + const path: string[] = [] + const pathIndexById = new Map() + let currentId: string | undefined = childId + while (currentId && lineageByChildId.has(currentId) && !processed.has(currentId)) { + const cycleStart = pathIndexById.get(currentId) + if (cycleStart !== undefined) { + for (let index = cycleStart; index < path.length; index += 1) { + cyclic.add(path[index]) + } + break + } + pathIndexById.set(currentId, path.length) + path.push(currentId) + currentId = lineageByChildId.get(currentId)?.parentWorktreeId + } + for (const id of path) { + processed.add(id) + } + } + + return cyclic +} + +export function projectResolvedWorktreeLineage( + worktrees: readonly T[], + lineageById: Readonly> +): WorktreeWithResolvedLineage[] { + const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) + const validLineageByChildId = new Map() + const childIdsByParentId = new Map() + + for (const child of worktrees) { + const childId = child.id + const lineage = lineageById[childId] + if (!lineage) { + continue + } + const parent = worktreeById.get(lineage.parentWorktreeId) + if (!parent || !isValidResolvedWorktreeLineageEdge(child, parent, lineage)) { + continue + } + validLineageByChildId.set(childId, lineage) + } + + const cyclicChildIds = getCyclicWorktreeLineageChildIds(validLineageByChildId) + for (const childId of cyclicChildIds) { + validLineageByChildId.delete(childId) + } + + for (const [childId, lineage] of validLineageByChildId) { + const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] + children.push(childId) + childIdsByParentId.set(lineage.parentWorktreeId, children) + } + + return worktrees.map((worktree) => { + const lineage = validLineageByChildId.get(worktree.id) ?? null + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], + lineage + } + }) +} diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 81082cddfc8..928eba66964 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -5,8 +5,11 @@ export type LineageScenario = { childId: string } -export async function seedLineageScenario(page: Page): Promise { - return page.evaluate(() => { +export async function seedLineageScenario( + page: Page, + options: { inlineOnly?: boolean } = {} +): Promise { + return page.evaluate(({ inlineOnly }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -35,38 +38,50 @@ export async function seedLineageScenario(page: Page): Promise if (!parent.instanceId || !child.instanceId) { throw new Error('Worktree lineage E2E needs instance-stamped worktrees') } + const lineage = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: Date.now() + } store.setState((current) => ({ worktreesByRepo: Object.fromEntries( Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ repoId, repoWorktrees.map((worktree) => { if (worktree.id === parent.id) { - return { ...worktree, displayName: 'E2E lineage parent', sortOrder: 0 } + return { + ...worktree, + displayName: 'E2E lineage parent', + sortOrder: 0, + ...(inlineOnly + ? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null } + : {}) + } } if (worktree.id === child.id) { - return { ...worktree, displayName: 'E2E lineage child', sortOrder: 1 } + return { + ...worktree, + displayName: 'E2E lineage child', + sortOrder: 1, + ...(inlineOnly + ? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage } + : {}) + } } return worktree }) ]) ), - worktreeLineageById: { - ...current.worktreeLineageById, - [child.id]: { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: Date.now() - } - } + worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage } })) store.getState().setActiveWorktree(parent.id) return { parentId: parent.id, childId: child.id } - }) + }, options) } export async function seedWorkspaceAgentStatus( diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 44ba2cf9424..65bcda63f7b 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -1,4 +1,6 @@ import type { Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -13,6 +15,20 @@ function worktreeOption(page: Page, worktreeId: string) { return worktreeRow(page, worktreeId) } +async function captureSidebarEvidence(page: Page, name: string): Promise { + if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') { + return + } + const outputDir = resolve(process.cwd(), 'pr-evidence') + mkdirSync(outputDir, { recursive: true }) + await page + .locator('[data-worktree-sidebar]') + .first() + .screenshot({ + path: resolve(outputDir, name) + }) +} + test.describe('Worktree Lineage', () => { test.describe.configure({ mode: 'serial' }) @@ -87,6 +103,27 @@ test.describe('Worktree Lineage', () => { await expect(childRow).toBeVisible() }) + test('renders legacy-only inline lineage when side-map hydration is absent', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true }) + const parentRow = worktreeOption(orcaPage, parentId) + const childRow = worktreeOption(orcaPage, childId) + + await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible() + await expect(childRow).toBeVisible() + await expect + .poll(async () => { + const [parentBox, childBox] = await Promise.all([ + parentRow.boundingBox(), + childRow.boundingBox() + ]) + return parentBox && childBox ? childBox.y > parentBox.y : false + }) + .toBe(true) + await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png') + }) + test('injects filtered parents structurally without showing a parent badge', async ({ orcaPage }) => { From b606117eedfa654030bac3bdc6f5f2a814ca794a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 22 Jul 2026 19:03:42 -0400 Subject: [PATCH 21/29] Fix paired remote terminal parse backpressure (#9683) * fix(terminal): defer remote output ACKs until parse * test(terminal): document synchronous credit claims --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../terminal-pane/pty-connection.test.ts | 18 ++- .../terminal-pane/pty-connection.ts | 50 ++++++-- .../terminal-pane/terminal-pty-ack-gate.ts | 41 +----- .../terminal-delivery-credit.test.ts | 44 +++++++ .../pane-manager/terminal-delivery-credit.ts | 49 ++++++++ .../remote-runtime-terminal-multiplexer.ts | 22 ++-- ...untime-terminal-parse-backpressure.test.ts | 118 ++++++++++++++++++ 7 files changed, 289 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 64704880bc3..7e70b5474b4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -13463,6 +13463,8 @@ describe('connectPanePty', () => { it('holds newer live bytes until a later replay frame has fully parsed', async () => { const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') enableActiveRuntimeEnvironment() const transport = createMockTransport('remote:env-1@@terminal-live-order') const callbacksRef: { @@ -13484,8 +13486,12 @@ describe('connectPanePty', () => { await flushAsyncTicks(8) expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) - callbacksRef.data?.('NEWER-LIVE\r\n') + const acknowledgeLiveFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { + callbacksRef.data?.('NEWER-LIVE\r\n') + }) expect(writes).not.toContain('NEWER-LIVE\r\n') + expect(acknowledgeLiveFrame).not.toHaveBeenCalled() for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { parseCallbacks.shift()?.() await flushAsyncTicks(4) @@ -13498,7 +13504,17 @@ describe('connectPanePty', () => { expect(replayIndex).toBeGreaterThan(0) expect(resetIndex).toBeGreaterThan(replayIndex) expect(liveIndex).toBeGreaterThan(resetIndex) + expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() + + callbacksRef.replay?.('stalled replay') + await flushAsyncTicks(8) + const acknowledgeDisposedFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeDisposedFrame, () => { + callbacksRef.data?.('LIVE-BEHIND-STALLED-REPLAY') + }) + expect(acknowledgeDisposedFrame).not.toHaveBeenCalled() binding.dispose() + expect(acknowledgeDisposedFrame).toHaveBeenCalledOnce() }) it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 821a9688d17..0a8505cde68 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -23,7 +23,10 @@ import { isStatefulRendererReplyCsiQuery, isStatelessRendererReplyCsiQuery } from '../../../../shared/terminal-reply-query-extraction' -import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate' +import { + deliverTerminalDataWithDeferredCredit, + takeCurrentTerminalDeliveryCredit +} from '@/lib/pane-manager/terminal-delivery-credit' import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' @@ -3312,7 +3315,13 @@ export function connectPanePty( let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false let deferredReattachLiveData: - | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | { + data: string + ptyId: string | null + streamGeneration: number + meta?: PtyDataMeta + ackCredit?: () => void + }[] | null = null let deferredReattachLiveDataChars = 0 let reattachLiveDataDeferralDepth = 0 @@ -5787,7 +5796,7 @@ export function connectPanePty( foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, // Why: claim the delivery's parse-deferred ACK credit (null outside a delivery); the FIRST scheduler write carries it all and fires when bytes are consumed. - ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined, + ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput @@ -6881,20 +6890,26 @@ export function connectPanePty( } if (deferredReattachLiveData !== null) { // Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded. - deferredReattachLiveData = deferredReattachLiveData.filter( - (chunk) => chunk.streamGeneration === streamGeneration - ) + deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => { + const keep = chunk.streamGeneration === streamGeneration + if (!keep) { + chunk.ackCredit?.() + } + return keep + }) deferredReattachLiveDataChars = deferredReattachLiveData.reduce( (total, chunk) => total + chunk.data.length, 0 ) const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + const ackCredit = takeCurrentTerminalDeliveryCredit() deferredReattachLiveData.push({ data: deferredData, ptyId: transport.getPtyId(), streamGeneration, - ...(meta ? { meta } : {}) + ...(meta ? { meta } : {}), + ...(ackCredit ? { ackCredit } : {}) }) deferredReattachLiveDataChars += deferredData.length // Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame. @@ -6906,6 +6921,7 @@ export function connectPanePty( ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 + removed?.ackCredit?.() dropped = true } if (dropped && deferredReattachLiveData[0]) { @@ -7111,6 +7127,9 @@ export function connectPanePty( const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) deferredReattachLiveDataOwners = new Map() if (disposed || !chunks) { + for (const chunk of chunks ?? []) { + chunk.ackCredit?.() + } return } // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. @@ -7121,9 +7140,16 @@ export function connectPanePty( chunk.streamGeneration !== currentGeneration || currentOwner?.failed === true ) { + chunk.ackCredit?.() continue } - dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + if (chunk.ackCredit) { + deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => { + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + }) + } else { + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + } deliveredDeferredChunks += 1 } if (deliveredDeferredChunks > 0) { @@ -8157,6 +8183,14 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. + for (const chunk of deferredReattachLiveData ?? []) { + chunk.ackCredit?.() + } + deferredReattachLiveData = null + deferredReattachLiveDataChars = 0 + reattachLiveDataDeferralDepth = 0 + deferredReattachLiveDataOwners = new Map() cancelPendingSafeFitContinuations(pane) pendingHiddenSnapshotFit = null pendingReattachFit = null diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index 31733aebc47..8340dfa1b48 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -1,4 +1,8 @@ import { e2eConfig } from '@/lib/e2e-config' +import { + deliverTerminalDataWithDeferredCredit, + takeCurrentTerminalDeliveryCredit +} from '@/lib/pane-manager/terminal-delivery-credit' type E2eTerminalPtyAckGateSnapshot = { gatedPtyCount: number @@ -89,25 +93,6 @@ export function ackPtyData(ptyId: string, chars: number): void { // true parse backpressure and main's producer flow control pauses the shell // instead of dropping. -type DeferredPtyAckCredit = { - ptyId: string - chars: number - claimed: boolean - credited: boolean -} - -let currentDeliveryCredit: DeferredPtyAckCredit | null = null - -function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void { - // Why fire-once: split queue chunks and discard paths may both touch the - // same delivery; the invariant is exactly one credit per delivered chunk. - if (credit.credited) { - return - } - credit.credited = true - ackPtyData(credit.ptyId, credit.chars) -} - /** Runs one pty:data delivery with a parse-deferred ACK credit. If the * handler hands bytes to the output scheduler, the claimed credit fires when * the scheduler consumes (writes or discards) them; any credit left @@ -118,28 +103,14 @@ export function deliverPtyDataWithDeferredAck( chars: number, deliver: () => void ): void { - const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false } - currentDeliveryCredit = credit - try { - deliver() - } finally { - currentDeliveryCredit = null - if (!credit.claimed) { - creditDeferredPtyAck(credit) - } - } + deliverTerminalDataWithDeferredCredit(() => ackPtyData(ptyId, chars), deliver) } /** Claims the in-progress delivery's credit for the output scheduler. Returns * a fire-once callback, or null when outside a delivery or already claimed * (only the FIRST scheduler write of a delivery carries the credit). */ export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null { - const credit = currentDeliveryCredit - if (!credit || credit.claimed) { - return null - } - credit.claimed = true - return () => creditDeferredPtyAck(credit) + return takeCurrentTerminalDeliveryCredit() } export function getProcessedPtyCharTotals(): Record { diff --git a/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts new file mode 100644 index 00000000000..01e20fb3fbd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('terminal delivery credit', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('restores an outer delivery after a nested delivery returns', async () => { + const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } = + await import('./terminal-delivery-credit') + const completeOuter = vi.fn() + const completeInner = vi.fn() + let outerCredit: (() => void) | null = null + let innerCredit: (() => void) | null = null + + deliverTerminalDataWithDeferredCredit(completeOuter, () => { + deliverTerminalDataWithDeferredCredit(completeInner, () => { + innerCredit = takeCurrentTerminalDeliveryCredit() + }) + outerCredit = takeCurrentTerminalDeliveryCredit() + }) + + expect(completeOuter).not.toHaveBeenCalled() + expect(completeInner).not.toHaveBeenCalled() + innerCredit!() + outerCredit!() + expect(completeInner).toHaveBeenCalledOnce() + expect(completeOuter).toHaveBeenCalledOnce() + }) + + it('auto-settles before a deferred consumer can claim the delivery', async () => { + const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } = + await import('./terminal-delivery-credit') + const complete = vi.fn() + let claimLater: (() => (() => void) | null) | null = null + + deliverTerminalDataWithDeferredCredit(complete, () => { + claimLater = takeCurrentTerminalDeliveryCredit + }) + + expect(complete).toHaveBeenCalledOnce() + expect(claimLater!()).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts new file mode 100644 index 00000000000..d467cedda23 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts @@ -0,0 +1,49 @@ +type TerminalDeliveryCredit = { + complete: () => void + claimed: boolean + credited: boolean +} + +// Why: consumers must claim during deliver(); after it returns this synchronous slot is restored and unclaimed credit settles. +let currentDeliveryCredit: TerminalDeliveryCredit | null = null + +function completeTerminalDeliveryCredit(credit: TerminalDeliveryCredit): void { + // Why: queue splitting and discard paths can both settle one delivery. + if (credit.credited) { + return + } + credit.credited = true + credit.complete() +} + +/** Defers producer credit until the output scheduler consumes or discards the delivery. */ +export function deliverTerminalDataWithDeferredCredit( + complete: () => void, + deliver: () => void +): void { + const credit: TerminalDeliveryCredit = { + complete, + claimed: false, + credited: false + } + const previousCredit = currentDeliveryCredit + currentDeliveryCredit = credit + try { + deliver() + } finally { + currentDeliveryCredit = previousCredit + if (!credit.claimed) { + completeTerminalDeliveryCredit(credit) + } + } +} + +/** Claims the current delivery for parse-deferred settlement by the output scheduler. */ +export function takeCurrentTerminalDeliveryCredit(): (() => void) | null { + const credit = currentDeliveryCredit + if (!credit || credit.claimed) { + return null + } + credit.claimed = true + return () => completeTerminalDeliveryCredit(credit) +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 4823c119430..12e4ea7f8b3 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -11,6 +11,7 @@ import { encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { e2eConfig } from '@/lib/e2e-config' +import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -472,7 +473,7 @@ class RemoteRuntimeTerminalMultiplexer { ? (span!.data as string) : '' : decodeTerminalStreamText(frame.payload) - try { + const deliverOutput = (): void => { if (!validSpan) { // Why: rendering malformed span JSON would expose protocol framing // as terminal text and lose its raw sequence accounting. @@ -501,15 +502,18 @@ class RemoteRuntimeTerminalMultiplexer { rawLength, ...(frame.opcode === TerminalStreamOpcode.OutputSpan ? { transformed: true } : {}) }) - } finally { - if (stream.acknowledgeOutput) { - if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { - stream.heldAckBytes += frame.payload.byteLength - } else { - this.acknowledgeOutput(stream, frame.payload.byteLength) - } - } } + if (!stream.acknowledgeOutput) { + deliverOutput() + return + } + deliverTerminalDataWithDeferredCredit(() => { + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } + }, deliverOutput) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts new file mode 100644 index 00000000000..645e99f4d7f --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +describe('remote terminal renderer backpressure', () => { + const sendBinary = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary: (bytes: Uint8Array) => void + } | null = null + + beforeEach(() => { + vi.resetModules() + sendBinary.mockReset() + callbacks = null + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + subscribe: vi.fn(async (_args, nextCallbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => { + callbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + }) + return { unsubscribe: vi.fn(), sendBinary } + }) + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('withholds server credit until xterm consumes the output frame', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const { takeCurrentTerminalDeliveryCredit } = + await import('../lib/pane-manager/terminal-delivery-credit') + const { writeTerminalOutput } = + await import('../lib/pane-manager/pane-terminal-output-scheduler') + const parsedCallbacks: (() => void)[] = [] + const terminal = { + write: vi.fn((_data: string, parsed?: () => void) => { + if (parsed) { + parsedCallbacks.push(parsed) + } + }) + } + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-codex', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { + onData: (data) => { + writeTerminalOutput(terminal, data, { + foreground: true, + ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined + }) + }, + onSnapshot: vi.fn() + } + }) + + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId: stream.streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback', seq: 0 }) + }) + ) + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId: stream.streamId, + seq: 2, + payload: new Uint8Array() + }) + ) + sendBinary.mockClear() + + const text = '\x1b[?1049h\x1b[?2026h\x1b[2J\x1b[H\x1b[31m-red 🙂 界\x1b[0m\x1b[?2026l' + const output = encodeTerminalStreamText(text) + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: stream.streamId, + seq: text.length, + payload: output + }) + ) + + expect(terminal.write).toHaveBeenCalledWith(text, expect.any(Function)) + expect(parsedCallbacks).toHaveLength(1) + expect(sentAckBytes()).toEqual([]) + + parsedCallbacks.shift()?.() + expect(sentAckBytes()).toEqual([output.byteLength]) + stream.close() + }) + + function sentAckBytes(): number[] { + return sendBinary.mock.calls.flatMap(([bytes]) => { + const frame = decodeTerminalStreamFrame(bytes) + if (frame?.opcode !== TerminalStreamOpcode.Ack) { + return [] + } + const payload = decodeTerminalStreamJson<{ bytes?: number }>(frame.payload) + return typeof payload?.bytes === 'number' ? [payload.bytes] : [] + }) + } +}) From 1d2cd33c83f32cd51364bda5155721e7fd062b50 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:07:51 -0700 Subject: [PATCH 22/29] fix(deps): resolve Dependabot security alerts (#10006) --- mobile/pnpm-lock.yaml | 133 ++--- package.json | 12 +- pnpm-lock.yaml | 1315 ++++++++++++++--------------------------- 3 files changed, 517 insertions(+), 943 deletions(-) diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index d5b49ba03f3..6ca7573778f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1494,6 +1494,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1942,56 +1948,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2064,56 +2062,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2575,42 +2565,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -3167,11 +3151,11 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -4175,8 +4159,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} @@ -4910,28 +4894,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5910,8 +5890,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -6585,6 +6565,18 @@ packages: utf-8-validate: optional: true + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -8098,6 +8090,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -9674,7 +9671,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9694,7 +9691,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9711,15 +9708,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9729,10 +9717,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9762,21 +9746,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -10236,12 +10205,12 @@ snapshots: dependencies: big-integer: 1.6.52 - brace-expansion@1.1.15: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -10839,11 +10808,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10867,7 +10836,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10890,7 +10859,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10901,7 +10870,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -10998,7 +10967,7 @@ snapshots: eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -11519,10 +11488,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.3 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.3: {} flow-enums-runtime@0.0.6: {} @@ -12933,7 +12902,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11 + ws: 7.5.13 yargs: 17.7.3 transitivePeerDependencies: - bufferutil @@ -12967,11 +12936,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.16 minimist@1.2.8: {} @@ -13342,7 +13311,7 @@ snapshots: react-devtools-core@6.1.5: dependencies: - shell-quote: 1.8.4 + shell-quote: 1.10.0 ws: 7.5.11 transitivePeerDependencies: - bufferutil @@ -13769,7 +13738,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} side-channel-list@1.0.1: dependencies: @@ -14071,10 +14040,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@6.0.3): - dependencies: - typescript: 6.0.3 - ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 @@ -14405,6 +14370,8 @@ snapshots: ws@7.5.11: {} + ws@7.5.13: {} + ws@8.21.0: {} xcode@3.0.1: diff --git a/package.json b/package.json index 918c0f00e6e..1c02ffd66c8 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "@xterm/addon-serialize": "0.15.0-beta.287", "@xterm/headless": "6.1.0-beta.287", "agent-browser": "~0.27.0", - "electron-updater": "^6.8.3", + "electron-updater": "^6.8.9", "i18next": "^26.3.1", "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", @@ -176,9 +176,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dompurify": "^3.4.11", + "dompurify": "^3.4.12", "electron": "^43.1.0", - "electron-builder": "^26.8.1", + "electron-builder": "^26.15.3", + "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", "happy-dom": "^20.9.0", @@ -213,7 +214,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", - "shadcn": "^4.7.0", + "shadcn": "^4.13.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", @@ -251,7 +252,8 @@ "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "pnpm": { "overrides": { - "monaco-editor>dompurify": "3.4.11" + "@modelcontextprotocol/sdk>@hono/node-server": "2.0.10", + "monaco-editor>dompurify": "3.4.12" }, "supportedArchitectures": { "os": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ed99b766af..4e8adb6af64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - monaco-editor>dompurify: 3.4.11 + '@modelcontextprotocol/sdk>@hono/node-server': 2.0.10 + monaco-editor>dompurify: 3.4.12 patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.287': @@ -39,7 +40,7 @@ importers: version: 1.7.6 '@linear/sdk': specifier: ^82.1.0 - version: 82.1.0(graphql@16.13.2) + version: 82.1.0(graphql@16.14.2) '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 @@ -53,8 +54,8 @@ importers: specifier: ~0.27.0 version: 0.27.0 electron-updater: - specifier: ^6.8.3 - version: 6.8.3 + specifier: ^6.8.9 + version: 6.8.9 i18next: specifier: ^26.3.1 version: 26.3.1(typescript@7.0.2) @@ -103,7 +104,7 @@ importers: version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.6.0) + version: 2.0.0(@types/node@25.9.5) '@electron/rebuild': specifier: ^4.2.0 version: 4.2.0 @@ -121,7 +122,7 @@ importers: version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -184,7 +185,7 @@ importers: version: 3.22.5 '@types/node': specifier: ^25.6.0 - version: 25.6.0 + version: 25.9.5 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -202,7 +203,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.287 version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) @@ -234,17 +235,20 @@ importers: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) dompurify: - specifier: ^3.4.11 - version: 3.4.11 + specifier: ^3.4.12 + version: 3.4.12 electron: specifier: ^43.1.0 version: 43.1.0 electron-builder: - specifier: ^26.8.1 - version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + specifier: ^26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + electron-builder-squirrel-windows: + specifier: ^26.15.3 + version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) @@ -345,8 +349,8 @@ importers: specifier: ^11.0.0 version: 11.0.0 shadcn: - specifier: ^4.7.0 - version: 4.7.0(@types/node@25.6.0)(typescript@7.0.2) + specifier: ^4.13.1 + version: 4.13.1(typescript@7.0.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -370,10 +374,10 @@ importers: version: 11.0.5 vite: specifier: ^7.3.6 - version: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + version: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -405,9 +409,6 @@ importers: packages: - 7zip-bin@5.2.0: - resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} @@ -571,10 +572,6 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} - '@develar/schema-utils@2.6.5': - resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} - engines: {node: '>= 8.9.0'} - '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -998,9 +995,9 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -1010,35 +1007,35 @@ packages: '@iconify/utils@3.1.1': resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} - '@inquirer/ansi@2.0.5': - resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/confirm@6.0.12': - resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@11.1.9': - resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@2.0.5': - resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/type@4.0.5': - resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1100,8 +1097,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@mswjs/interceptors@0.41.8': - resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} '@napi-rs/canvas-android-arm64@0.1.100': @@ -1187,10 +1184,18 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1577,6 +1582,20 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -1591,9 +1610,6 @@ packages: '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/primitive@1.1.5': resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} @@ -1714,15 +1730,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-compose-refs@1.1.3': resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: @@ -1745,15 +1752,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-context@1.2.0': resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} peerDependencies: @@ -1763,19 +1761,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-dialog@1.1.19': resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} peerDependencies: @@ -1798,19 +1783,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-dismissable-layer@1.1.15': resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} peerDependencies: @@ -1837,15 +1809,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-focus-guards@1.1.4': resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} peerDependencies: @@ -1868,19 +1831,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-form@0.1.12': resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==} peerDependencies: @@ -1907,15 +1857,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: @@ -2042,32 +1983,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-presence@1.1.7': resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} peerDependencies: @@ -2081,32 +1996,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-primitive@2.1.7': resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} peerDependencies: @@ -2211,24 +2100,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-slot@1.3.0': resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} peerDependencies: @@ -2329,15 +2200,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-callback-ref@1.1.2': resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} peerDependencies: @@ -2347,15 +2209,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-controllable-state@1.2.3': resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: @@ -2365,15 +2218,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-effect-event@0.0.3': resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: @@ -2383,15 +2227,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-escape-keydown@1.1.3': resolution: {integrity: sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==} peerDependencies: @@ -2410,15 +2245,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-layout-effect@1.1.2': resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: @@ -3176,11 +3002,8 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - - '@types/plist@3.0.5': - resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -3223,9 +3046,6 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@types/verror@1.10.11': - resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -3401,10 +3221,6 @@ packages: resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - '@xmldom/xmldom@0.9.10': - resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} - engines: {node: '>=14.6'} - '@xterm/addon-fit@0.12.0-beta.287': resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==} peerDependencies: @@ -3476,14 +3292,6 @@ packages: ajv: optional: true - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -3511,15 +3319,12 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - app-builder-bin@5.0.0-alpha.12: - resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} - - app-builder-lib@26.8.1: - resolution: {integrity: sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.8.1 - electron-builder-squirrel-windows: 26.8.1 + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3534,9 +3339,9 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -3546,10 +3351,6 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - async-exit-hook@2.0.1: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} @@ -3564,6 +3365,9 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3590,22 +3394,25 @@ packages: peerDependencies: react: '>=17.0.1' - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -3620,19 +3427,17 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} - builder-util-runtime@9.5.1: - resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} - builder-util@26.8.1: - resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -3642,6 +3447,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3730,10 +3539,6 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@2.1.0: - resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} - engines: {node: '>=8'} - cli-truncate@5.2.0: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} @@ -3843,8 +3648,8 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -3869,9 +3674,6 @@ packages: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} - crc@3.8.0: - resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} - cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -4046,10 +3848,6 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -4147,14 +3945,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.8.1: - resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} - - dmg-license@1.0.11: - resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} - engines: {node: '>=8'} - os: [darwin] - hasBin: true + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -4162,8 +3954,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dompurify@3.4.11: - resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -4181,6 +3973,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -4193,22 +3988,22 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.8.1: - resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} - electron-builder@26.8.1: - resolution: {integrity: sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==} + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.8.1: - resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} electron-to-chromium@1.5.351: resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} - electron-updater@6.8.3: - resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} @@ -4399,10 +4194,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extsprintf@1.4.1: - resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} - engines: {'0': node >=0.6.0} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4414,20 +4205,17 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4444,10 +4232,6 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4478,10 +4262,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4494,6 +4274,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4599,8 +4383,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} hachure-fill@0.5.2: @@ -4681,8 +4465,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -4741,11 +4525,6 @@ packages: typescript: optional: true - iconv-corefoundation@1.1.7: - resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} - engines: {node: ^8.11.2 || >=10} - os: [darwin] - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4754,9 +4533,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4895,6 +4671,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -4932,8 +4711,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsesc@3.1.0: @@ -4947,9 +4726,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -5453,9 +5229,6 @@ packages: resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} engines: {node: '>=22.12.0'} - node-addon-api@1.7.2: - resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} - node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} @@ -5465,20 +5238,14 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp@12.3.0: resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -5698,6 +5465,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -5712,10 +5483,6 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} - plist@3.1.1: - resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} - engines: {node: '>=10.4.0'} - pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -5768,6 +5535,9 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -5829,9 +5599,12 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} @@ -5957,6 +5730,9 @@ packages: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -6085,6 +5861,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6139,14 +5918,15 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.7.0: - resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} + shadcn@4.13.1: + resolution: {integrity: sha512-pSNPND8mVWGBytdd8l4Cksg7MyRZOsv28HpvNCtFtLwZoxLSGM6v3NMUpmpbOaIoreopS/UOpQvnCyttOHVLAQ==} + engines: {node: '>=20.18.1'} hasBin: true shebang-command@2.0.0: @@ -6218,10 +5998,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slice-ansi@3.0.0: - resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} - engines: {node: '>=8'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -6230,10 +6006,6 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -6309,6 +6081,9 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6371,8 +6146,8 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} engines: {node: '>=18'} temp-file@3.4.0: @@ -6410,11 +6185,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.30: - resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} - tldts@7.0.30: - resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} hasBin: true tmp-promise@3.0.3: @@ -6432,8 +6207,8 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} trim-lines@3.0.1: @@ -6472,8 +6247,8 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@5.6.0: - resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} type-is@2.1.0: @@ -6499,8 +6274,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@6.27.0: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} @@ -6553,15 +6328,15 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6605,10 +6380,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verror@1.10.1: - resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} - engines: {node: '>=0.6.0'} - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -6715,9 +6486,8 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -6824,8 +6594,8 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} yocto-queue@0.1.0: @@ -6874,8 +6644,6 @@ packages: snapshots: - 7zip-bin@5.2.0: {} - '@adobe/css-tools@4.5.0': {} '@antfu/install-pkg@1.1.0': @@ -7090,11 +6858,6 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.15.0 - ajv-keywords: 3.5.2(ajv@6.15.0) - '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: react: 19.2.7 @@ -7143,9 +6906,9 @@ snapshots: dependencies: electron: 43.1.0 - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@electron-toolkit/utils@4.0.0(electron@43.1.0)': dependencies: @@ -7205,7 +6968,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.1 + plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -7228,7 +6991,7 @@ snapshots: dir-compare: 4.2.0 fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.1 + plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -7416,13 +7179,13 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: - graphql: 16.13.2 + graphql: 16.14.2 - '@hono/node-server@1.19.14(hono@4.12.25)': + '@hono/node-server@2.0.10(hono@4.12.31)': dependencies: - hono: 4.12.25 + hono: 4.12.31 '@iconify/types@2.0.0': {} @@ -7432,32 +7195,37 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@inquirer/ansi@2.0.5': {} + '@inquirer/ansi@2.0.7': + optional: true - '@inquirer/confirm@6.0.12(@types/node@25.6.0)': + '@inquirer/confirm@6.1.1(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.2.1(@types/node@25.9.5) + '@inquirer/type': 4.0.7(@types/node@25.9.5) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true - '@inquirer/core@11.1.9(@types/node@25.6.0)': + '@inquirer/core@11.2.1(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.5) cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true - '@inquirer/figures@2.0.5': {} + '@inquirer/figures@2.0.7': + optional: true - '@inquirer/type@4.0.5(@types/node@25.6.0)': + '@inquirer/type@4.0.7(@types/node@25.9.5)': optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true '@isaacs/fs-minipass@4.0.1': dependencies: @@ -7482,9 +7250,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@linear/sdk@82.1.0(graphql@16.13.2)': + '@linear/sdk@82.1.0(graphql@16.14.2)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) transitivePeerDependencies: - graphql @@ -7507,7 +7275,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) + '@hono/node-server': 2.0.10(hono@4.12.31) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7517,7 +7285,7 @@ snapshots: eventsource-parser: 3.0.8 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 + hono: 4.12.31 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7538,7 +7306,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mswjs/interceptors@0.41.8': + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -7546,6 +7314,7 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + optional: true '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -7601,8 +7370,12 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.4.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7615,16 +7388,20 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@2.2.0': + optional: true - '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/deferred-promise@3.0.0': + optional: true '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 + optional: true - '@open-draft/until@2.1.0': {} + '@open-draft/until@2.1.0': + optional: true '@oxfmt/binding-android-arm-eabi@0.52.0': optional: true @@ -7818,6 +7595,28 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -7830,8 +7629,6 @@ snapshots: '@radix-ui/number@1.1.2': {} - '@radix-ui/primitive@1.1.3': {} - '@radix-ui/primitive@1.1.5': {} '@radix-ui/react-accessible-icon@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -7948,12 +7745,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -7973,40 +7764,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-context@1.1.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7) - aria-hidden: 1.2.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8035,19 +7798,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8076,12 +7826,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -8099,17 +7843,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-form@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8141,13 +7874,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8317,26 +8043,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8346,24 +8052,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) @@ -8495,20 +8183,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) @@ -8628,26 +8302,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) @@ -8656,13 +8316,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8670,13 +8323,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-escape-keydown@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8690,12 +8336,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -8921,12 +8561,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -9237,7 +8877,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -9378,7 +9018,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/geojson@7946.0.16': {} @@ -9392,7 +9032,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/mdast@4.0.4': dependencies: @@ -9408,19 +9048,13 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.6.0': + '@types/node@25.9.5': dependencies: - undici-types: 7.19.2 - - '@types/plist@3.0.5': - dependencies: - '@types/node': 25.6.0 - xmlbuilder: 15.1.1 - optional: true + undici-types: 7.24.6 '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: @@ -9432,19 +9066,21 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/retry@0.12.0': {} '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 - '@types/statuses@2.0.6': {} + '@types/statuses@2.0.6': + optional: true '@types/trusted-types@2.0.7': optional: true @@ -9457,14 +9093,11 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@types/verror@1.10.11': - optional: true - '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@typescript-eslint/types@8.60.0': {} @@ -9535,7 +9168,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -9543,7 +9176,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9556,14 +9189,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2) - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -9591,8 +9224,6 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': {} - '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) @@ -9644,21 +9275,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv-keywords@3.5.2(ajv@6.15.0): - dependencies: - ajv: 6.15.0 - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -9678,11 +9298,8 @@ snapshots: ansi-styles@6.2.3: {} - app-builder-bin@5.0.0-alpha.12: {} - - app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): dependencies: - '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 @@ -9691,34 +9308,40 @@ snapshots: '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.2.0 + '@peculiar/webcrypto': 1.7.1 '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 async-exit-hook: 2.0.1 - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.8.1(dmg-builder@26.8.1) - electron-publish: 26.8.1 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.7.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 + pkijs: 3.4.0 plist: 3.1.0 proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.16 + tar: 7.5.20 temp-file: 3.4.0 tiny-async-pool: 1.3.0 + unzipper: 0.12.5 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -9737,8 +9360,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 - assert-plus@1.0.0: - optional: true + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 assertion-error@2.0.1: {} @@ -9746,9 +9372,6 @@ snapshots: dependencies: tslib: 2.8.1 - astral-regex@2.0.0: - optional: true - async-exit-hook@2.0.1: {} async@3.2.6: {} @@ -9757,6 +9380,8 @@ snapshots: at-least-node@1.0.0: {} + aws4@1.13.2: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9775,10 +9400,12 @@ snapshots: dependencies: react: 19.2.7 - body-parser@2.2.2: + bluebird@3.7.2: {} + + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 @@ -9792,16 +9419,16 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@1.1.14: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -9819,35 +9446,27 @@ snapshots: buffer-from@1.1.2: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - optional: true - buildcheck@0.0.7: optional: true - builder-util-runtime@9.5.1: + builder-util-runtime@9.7.0: dependencies: debug: 4.4.3 sax: 1.6.0 transitivePeerDependencies: - supports-color - builder-util@26.8.1: + builder-util@26.15.3: dependencies: - 7zip-bin: 5.2.0 '@types/debug': 4.1.13 - app-builder-bin: 5.0.0-alpha.12 - builder-util-runtime: 9.5.1 + builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.2.0 + js-yaml: 4.3.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -9862,6 +9481,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + cac@6.7.14: {} cacheable-lookup@5.0.4: {} @@ -9931,18 +9552,13 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@2.1.0: - dependencies: - slice-ansi: 3.0.0 - string-width: 4.2.3 - optional: true - cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 string-width: 8.2.1 - cli-width@4.1.0: {} + cli-width@4.1.0: + optional: true cliui@6.0.0: dependencies: @@ -9964,10 +9580,10 @@ snapshots: cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: @@ -10021,11 +9637,11 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} - - core-util-is@1.0.2: + cookie@1.1.1: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -10043,7 +9659,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 7.0.2 @@ -10054,11 +9670,6 @@ snapshots: nan: 2.26.2 optional: true - crc@3.8.0: - dependencies: - buffer: 5.7.1 - optional: true - cross-dirname@0.1.0: optional: true @@ -10258,8 +9869,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 - data-uri-to-buffer@4.0.1: {} - dayjs@1.11.20: {} debug@4.4.3: @@ -10335,36 +9944,21 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 - dmg-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 fs-extra: 10.1.0 - iconv-lite: 0.6.3 - js-yaml: 4.2.0 - optionalDependencies: - dmg-license: 1.0.11 + js-yaml: 4.3.0 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color - dmg-license@1.0.11: - dependencies: - '@types/plist': 3.0.5 - '@types/verror': 1.10.11 - ajv: 6.15.0 - crc: 3.8.0 - iconv-corefoundation: 1.1.7 - plist: 3.1.1 - smart-buffer: 4.2.0 - verror: 1.10.1 - optional: true - dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} - dompurify@3.4.11: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10382,6 +9976,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -10395,36 +9993,37 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 - yargs: 17.7.2 + yargs: 17.7.3 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color - electron-publish@26.8.1: + electron-publish@26.15.3: dependencies: '@types/fs-extra': 9.0.13 - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 form-data: 4.0.6 fs-extra: 10.1.0 @@ -10435,11 +10034,11 @@ snapshots: electron-to-chromium@1.5.351: {} - electron-updater@6.8.3: + electron-updater@6.8.9: dependencies: - builder-util-runtime: 9.5.1 + builder-util-runtime: 9.7.0 fs-extra: 10.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -10448,7 +10047,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + electron-vite@5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -10456,7 +10055,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -10677,7 +10276,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -10709,9 +10308,6 @@ snapshots: extend@3.0.2: {} - extsprintf@1.4.1: - optional: true - fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -10724,19 +10320,20 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - - fast-string-truncated-width@3.0.3: {} + fast-string-truncated-width@3.0.3: + optional: true fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + optional: true - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} - fast-wrap-ansi@0.2.0: + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 + optional: true fastq@1.20.1: dependencies: @@ -10750,11 +10347,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -10795,10 +10387,6 @@ snapshots: format@0.2.2: {} - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -10809,6 +10397,12 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 @@ -10934,13 +10528,13 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.2: {} + graphql@16.14.2: {} hachure-fill@0.5.2: {} happy-dom@20.9.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -11090,11 +10684,12 @@ snapshots: headers-polyfill@5.0.1: dependencies: '@types/set-cookie-parser': 2.4.10 - set-cookie-parser: 3.1.0 + set-cookie-parser: 3.1.2 + optional: true highlight.js@11.11.1: {} - hono@4.12.25: {} + hono@4.12.31: {} hosted-git-info@4.1.0: dependencies: @@ -11149,12 +10744,6 @@ snapshots: optionalDependencies: typescript: 7.0.2 - iconv-corefoundation@1.1.7: - dependencies: - cli-truncate: 2.1.0 - node-addon-api: 1.7.2 - optional: true - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -11163,9 +10752,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: - optional: true - ignore@5.3.2: {} ignore@7.0.5: {} @@ -11233,7 +10819,8 @@ snapshots: is-interactive@2.0.0: {} - is-node-process@1.2.0: {} + is-node-process@1.2.0: + optional: true is-number@7.0.0: {} @@ -11257,6 +10844,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -11281,7 +10870,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -11291,8 +10880,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -11683,7 +11270,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.11 + dompurify: 3.4.12 es-toolkit: 1.46.1 katex: 0.16.45 khroma: 2.1.0 @@ -11932,19 +11519,19 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.16 minimatch@5.1.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimist@1.2.8: {} @@ -11967,19 +11554,19 @@ snapshots: monaco-editor@0.55.1: dependencies: - dompurify: 3.4.11 + dompurify: 3.4.12 marked: 14.0.0 ms@2.1.3: {} - msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2): + msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2): dependencies: - '@inquirer/confirm': 6.0.12(@types/node@25.6.0) - '@mswjs/interceptors': 0.41.8 + '@inquirer/confirm': 6.1.1(@types/node@25.9.5) + '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.2 + graphql: 16.14.2 headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -11988,16 +11575,18 @@ snapshots: rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 - type-fest: 5.6.0 + tough-cookie: 6.0.2 + type-fest: 5.8.0 until-async: 3.0.2 - yargs: 17.7.2 + yargs: 17.7.3 optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: - '@types/node' + optional: true - mute-stream@3.0.0: {} + mute-stream@3.0.0: + optional: true nan@2.26.2: optional: true @@ -12010,9 +11599,6 @@ snapshots: dependencies: semver: 7.8.1 - node-addon-api@1.7.2: - optional: true - node-addon-api@4.3.0: optional: true @@ -12022,14 +11608,6 @@ snapshots: dependencies: semver: 7.8.1 - node-domexception@1.0.0: {} - - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - node-gyp@12.3.0: dependencies: env-paths: 2.2.1 @@ -12038,11 +11616,13 @@ snapshots: nopt: 9.0.0 proc-log: 6.1.0 semver: 7.8.1 - tar: 7.5.16 + tar: 7.5.20 tinyglobby: 0.2.16 undici: 6.27.0 which: 6.0.1 + node-int64@0.4.0: {} + node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): dependencies: node-addon-api: 7.1.1 @@ -12127,7 +11707,8 @@ snapshots: orderedmap@2.1.1: {} - outvariant@1.4.3: {} + outvariant@1.4.3: + optional: true oxfmt@0.52.0: dependencies: @@ -12255,7 +11836,8 @@ snapshots: path-key@4.0.0: {} - path-to-regexp@6.3.0: {} + path-to-regexp@6.3.0: + optional: true path-to-regexp@8.4.2: {} @@ -12281,6 +11863,15 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + playwright-core@1.59.1: {} playwright@1.59.1: @@ -12295,12 +11886,6 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - plist@3.1.1: - dependencies: - '@xmldom/xmldom': 0.9.10 - base64-js: 1.5.1 - xmlbuilder: 15.1.1 - pngjs@5.0.0: {} pngjs@7.0.0: {} @@ -12346,6 +11931,8 @@ snapshots: proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} promise-retry@2.0.1: @@ -12445,7 +12032,11 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} qrcode@1.5.4: dependencies: @@ -12618,6 +12209,16 @@ snapshots: transitivePeerDependencies: - supports-color + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -12753,7 +12354,8 @@ snapshots: retry@0.13.1: {} - rettime@0.11.11: {} + rettime@0.11.11: + optional: true reusify@1.1.0: {} @@ -12833,6 +12435,8 @@ snapshots: rw@1.3.3: {} + safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.4: @@ -12892,11 +12496,12 @@ snapshots: set-blocking@2.0.0: {} - set-cookie-parser@3.1.0: {} + set-cookie-parser@3.1.2: + optional: true setprototypeof@1.2.0: {} - shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): + shadcn@4.13.1(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 @@ -12915,10 +12520,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.6 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2) - node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 postcss: 8.5.14 @@ -12929,12 +12531,12 @@ snapshots: tailwind-merge: 3.5.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 + undici: 7.28.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - - '@types/node' - babel-plugin-macros - supports-color - typescript @@ -13002,13 +12604,6 @@ snapshots: sisteransi@1.0.5: {} - slice-ansi@3.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - optional: true - slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -13019,9 +12614,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: - optional: true - smol-toml@1.6.1: {} sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -13065,7 +12657,8 @@ snapshots: stdin-discarder@0.3.2: {} - strict-event-emitter@0.5.1: {} + strict-event-emitter@0.5.1: + optional: true string-argv@0.3.2: {} @@ -13086,6 +12679,10 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13135,7 +12732,8 @@ snapshots: dependencies: has-flag: 4.0.0 - tagged-tag@1.0.0: {} + tagged-tag@1.0.0: + optional: true tailwind-merge@3.5.0: {} @@ -13143,7 +12741,7 @@ snapshots: tapable@2.3.3: {} - tar@7.5.16: + tar@7.5.20: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -13182,11 +12780,13 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.30: {} + tldts-core@7.4.9: + optional: true - tldts@7.0.30: + tldts@7.4.9: dependencies: - tldts-core: 7.0.30 + tldts-core: 7.4.9 + optional: true tmp-promise@3.0.3: dependencies: @@ -13200,9 +12800,10 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@6.0.1: + tough-cookie@6.0.2: dependencies: - tldts: 7.0.30 + tldts: 7.4.9 + optional: true trim-lines@3.0.1: {} @@ -13236,9 +12837,10 @@ snapshots: type-fest@0.13.1: optional: true - type-fest@5.6.0: + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 + optional: true type-is@2.1.0: dependencies: @@ -13277,12 +12879,11 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: {} + undici-types@7.24.6: {} undici@6.27.0: {} - undici@7.28.0: - optional: true + undici@7.28.0: {} unicorn-magic@0.3.0: {} @@ -13335,7 +12936,16 @@ snapshots: unpipe@1.0.0: {} - until-async@3.0.2: {} + until-async@3.0.2: + optional: true + + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -13343,10 +12953,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -13376,13 +12982,6 @@ snapshots: vary@1.1.2: {} - verror@1.10.1: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - optional: true - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -13398,7 +12997,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) @@ -13407,16 +13006,16 @@ snapshots: rollup: 4.60.3 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 yaml: 2.8.4 - vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + vitest@4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -13433,10 +13032,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 happy-dom: 20.9.0 transitivePeerDependencies: - msw @@ -13451,7 +13050,13 @@ snapshots: web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: {} + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 whatwg-mimetype@3.0.0: {} @@ -13545,7 +13150,7 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 - yargs@17.7.2: + yargs@17.7.3: dependencies: cliui: 8.0.1 escalade: 3.2.0 From bc57cf87872e55bb6c26221b791d287b3ba5fa4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:09:19 -0700 Subject: [PATCH 23/29] fix(sidebar): use automation icon for Hide automation-created filter (#10000) The workspace-options filter row used the Workflow icon while the Automations nav item and page use CalendarClock. Match them so the filter clearly maps to automation-created workspaces. --- src/renderer/src/components/sidebar/SidebarFilter.tsx | 4 ++-- .../src/components/sidebar/SidebarWorkspaceFilterSection.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 9927e4e9db2..3e66fe5cc48 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server, Workflow } from 'lucide-react' +import { CalendarClock, Check, FolderPlus, GitBranch, ListFilter, Moon, Server } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { @@ -200,7 +200,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ onChange={setHideDefaultBranchWorkspace} /> } + icon={} label={translate( 'auto.components.sidebar.SidebarFilter.automationCreated', 'Hide automation-created' diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index cd0b5d8434f..8190acc5d22 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { GitBranch, Moon, Workflow } from 'lucide-react' +import { CalendarClock, GitBranch, Moon } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -40,7 +40,7 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter onChange={setHideDefaultBranchWorkspace} /> } + icon={} label={translate( 'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated', 'Hide automation-created' From a356b9d5c2e7ff58d9309274ef6f0c9b80dc27f0 Mon Sep 17 00:00:00 2001 From: Saad Khan Date: Wed, 22 Jul 2026 19:10:53 -0400 Subject: [PATCH 24/29] fix(worktrees): show CLI-created local worktrees in the sidebar while a remote runtime is active (#6628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktrees): refresh local worktrees in the sidebar while a remote runtime is active When a remote runtime is active, a local `worktrees:changed` event for an unbound repo was dropped by the renderer guard in useIpcEvents. Worktrees created outside Orca for that repo (e.g. `orca worktree create` from a CLI or automation flow) therefore stayed invisible in the sidebar until an app restart, even though their sessions were already running. The guard existed because an unbound repo's list fetch routes to the active runtime (settingsForKnownRepoOwner's unbound fall-through), so refreshing with local worktree ids could query — and purge against — the remote host. Instead of dropping the event, pin the refresh to the local host (forceLocalOwner): fetch the worktree list against the local owner and merge additively. The merge is host-scoped and the deletion-purge is skipped on this path, so it only ever adds local-host worktrees and never overwrites the active runtime's worktree state. A genuinely-removed local worktree is reclaimed by the next unguarded full refresh. * test(e2e): regression — CLI-created worktree visible while a remote runtime is active Drives the real `orca worktree create` path: the CLI RuntimeClient calls `worktree.create` over the app's socket, registering a managed worktree and firing the `worktrees:changed` IPC the renderer listens for. Stages a remote runtime as active by injecting `activeRuntimeEnvironmentId` into the renderer store, so no real remote host is needed. Fails on the prior behavior (the worktree never appears while a runtime is active) and passes with this fix. * fix(worktrees): pin local lineage refresh during runtime activity Co-authored-by: Orca * review: trim comments to house style, normalize queue coalescing to booleans * review: sweep rename-grace expiry before early returns in worktrees:changed handler * review: document accepted workspace-space gap, drop imprecise 'additive' wording * fix(worktrees): route duplicate local repo events locally * fix(worktrees): tag local worktree events at origin, gate purge skip on runtime overlap * test: pin origin-based forceLocalOwner with a no-runtime local event assertion --------- Co-authored-by: brennanb2025 Co-authored-by: Orca Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/renderer/src/hooks/useIpcEvents.test.ts | 45 ++++++++- src/renderer/src/hooks/useIpcEvents.ts | 55 ++++++++--- .../worktree-change-refresh-queue.test.ts | 53 ++++++++-- .../hooks/worktree-change-refresh-queue.ts | 24 ++++- .../src/store/slices/worktree-helpers.ts | 7 +- .../src/store/slices/worktrees.test.ts | 97 ++++++++++++++++++- src/renderer/src/store/slices/worktrees.ts | 29 +++++- ...worktree-visibility-runtime-active.spec.ts | 79 +++++++++++++++ 8 files changed, 344 insertions(+), 45 deletions(-) create mode 100644 tests/e2e/local-worktree-visibility-runtime-active.spec.ts diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index b00a64f2e35..be45ff42e98 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4319,9 +4319,16 @@ describe('useIpcEvents CLI-created worktree activation', () => { }) }) - it('refreshes active runtime worktrees from remote client events', async () => { + it('routes local and runtime worktree events to their owning hosts', async () => { const fetchWorktrees = vi.fn() const fetchWorktreeLineage = vi.fn() + // Mutable so the test can drop the runtime mid-run and prove the local flag + // is origin-based, not a sample of runtime state. + const mockSettings: { activeRuntimeEnvironmentId: string | null; terminalFontSize: number } = { + activeRuntimeEnvironmentId: 'env-1', + terminalFontSize: 13 + } + let localWorktreesOnChanged: ((data: { repoId: string }) => void) | undefined let runtimeOnResponse: ((response: unknown) => void) | undefined const runtimeSubscribe = vi.fn(async (_args, callbacks) => { runtimeOnResponse = (callbacks as { onResponse: (response: unknown) => void }).onResponse @@ -4384,7 +4391,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { enqueueSshCredentialRequest: vi.fn(), removeSshCredentialRequest: vi.fn(), clearTabPtyId: vi.fn(), - settings: { activeRuntimeEnvironmentId: 'env-1', terminalFontSize: 13 } + settings: mockSettings }) } })) @@ -4416,7 +4423,10 @@ describe('useIpcEvents CLI-created worktree activation', () => { api: { repos: { onChanged: () => () => {} }, worktrees: { - onChanged: () => () => {}, + onChanged: (callback: (data: { repoId: string }) => void) => { + localWorktreesOnChanged = callback + return () => {} + }, onBaseStatus: () => () => {}, onRemoteBranchConflict: () => () => {} }, @@ -4527,6 +4537,31 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, expect.any(Object) ) + if (!localWorktreesOnChanged) { + throw new Error('Expected local worktree event callback') + } + localWorktreesOnChanged({ repoId: 'repo-1' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', { forceLocalOwner: true }) + expect(fetchWorktreeLineage).toHaveBeenCalledWith({ forceLocalOwner: true }) + + fetchWorktrees.mockClear() + fetchWorktreeLineage.mockClear() + // With no runtime active the flag must still be true — it marks the event's + // local origin; sampling runtime state here would regress to false. + mockSettings.activeRuntimeEnvironmentId = null + localWorktreesOnChanged({ repoId: 'repo-1' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', { forceLocalOwner: true }) + expect(fetchWorktreeLineage).toHaveBeenCalledWith({ forceLocalOwner: true }) + + fetchWorktrees.mockClear() + fetchWorktreeLineage.mockClear() + mockSettings.activeRuntimeEnvironmentId = 'env-1' if (!runtimeOnResponse) { throw new Error('Expected runtime client event callbacks') } @@ -4537,8 +4572,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(fetchWorktrees).toHaveBeenCalledWith('repo-1') - expect(fetchWorktreeLineage).toHaveBeenCalledTimes(1) + expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', undefined) + expect(fetchWorktreeLineage).toHaveBeenCalledWith(undefined) }) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 94027336884..6fb12148b34 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -848,8 +848,11 @@ export function useIpcEvents(): void { const handleWorktreesChanged = async ( repoId: string, - renamed?: { oldWorktreeId: string; newWorktreeId: string } + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + options?: { forceLocalOwner?: boolean } ): Promise => { + const localRefreshStartedWithRuntime = + options?.forceLocalOwner === true && isRuntimeEnvironmentActive() // Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion. const renamedWasActive = renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId @@ -865,18 +868,40 @@ export function useIpcEvents(): void { const before = getAuthoritativeDetectedWorktreeIds(state, repoId) ?? getVisibleWorktreeIdsForRepo(state, repoId) - await state.fetchWorktrees(repoId) - await useAppStore.getState().fetchWorktreeLineage() + await state.fetchWorktrees( + repoId, + options?.forceLocalOwner ? { forceLocalOwner: true } : undefined + ) + await useAppStore + .getState() + .fetchWorktreeLineage(options?.forceLocalOwner ? { forceLocalOwner: true } : undefined) // Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select. if (renamedWasActive && renamed) { useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) } + // Sweep expired rename-grace entries before any early return, else forced-local + // (or non-authoritative) events let the map grow for the session. + const now = Date.now() + for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { + if (expiry <= now) { + recentlyRenamedWorktreeIdExpiry.delete(id) + } + } + // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping + // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). + // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the + // workspace-space entry survives until the next local-only rescan. + if ( + options?.forceLocalOwner && + (localRefreshStartedWithRuntime || isRuntimeEnvironmentActive()) + ) { + return + } const afterState = useAppStore.getState() const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId) if (!after) { return } - const now = Date.now() const removed: string[] = [] for (const id of before) { if (after.has(id)) { @@ -889,11 +914,6 @@ export function useIpcEvents(): void { } removed.push(id) } - for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { - if (expiry <= now) { - recentlyRenamedWorktreeIdExpiry.delete(id) - } - } if (removed.length > 0) { console.warn( `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, @@ -1094,12 +1114,14 @@ export function useIpcEvents(): void { repoId: string renamed?: { oldWorktreeId: string; newWorktreeId: string } }) => { - if (isRuntimeEnvironmentActive()) { - // Why: local worktree events carry local repo ids; fetching the runtime with them can purge or overwrite server state. - return - } - // A folder rename changes the worktree id; handleWorktreesChanged re-keys state and shields it from the deletion diff. - worktreeChangeRefreshQueue.enqueue(data) + // Why: preserve this event's local origin across queue delays and runtime + // focus changes; otherwise an unbound repo can refresh from the wrong host. + // A folder rename changes the worktree id; handleWorktreesChanged re-keys + // state and shields it from the deletion diff. + worktreeChangeRefreshQueue.enqueue({ + ...data, + forceLocalOwner: true + }) } ) ) @@ -1108,7 +1130,8 @@ export function useIpcEvents(): void { unsubs.push( window.api.worktrees.onHeadIdentitiesChanged((data) => { if (isRuntimeEnvironmentActive()) { - // Why: local worktree events carry local repo ids (see onChanged). + // Why: local worktree events carry local repo ids; the local-pinned list + // refresh (onChanged) covers local rows while a runtime is active. return } const state = useAppStore.getState() diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts index be61b8dc3b0..7c04b18cfca 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -28,13 +28,13 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-1' }) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith('repo-1', undefined) + expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: undefined }) firstRefresh.resolve() await flushPromises() expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, { forceLocalOwner: undefined }) }) it('does not overlap refreshes for the same repo', async () => { @@ -73,8 +73,8 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-2' }) expect(handler).toHaveBeenCalledTimes(2) - expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined) + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, { forceLocalOwner: undefined }) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined, { forceLocalOwner: undefined }) repoOneRefresh.resolve() await flushPromises() @@ -100,8 +100,10 @@ describe('createWorktreeChangeRefreshQueue', () => { await flushPromises() expect(handler).toHaveBeenCalledTimes(3) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) - expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined }) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, { + forceLocalOwner: undefined + }) } finally { consoleError.mockRestore() } @@ -116,8 +118,8 @@ describe('createWorktreeChangeRefreshQueue', () => { queue.enqueue({ repoId: 'repo-1', renamed }) await flushPromises() - expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) + expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, { forceLocalOwner: undefined }) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined }) }) it('keeps a plain refresh queued after a rename', async () => { @@ -136,8 +138,39 @@ describe('createWorktreeChangeRefreshQueue', () => { await flushPromises() expect(handler).toHaveBeenCalledTimes(3) - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed) - expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined }) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, { forceLocalOwner: undefined }) + }) + + it('threads forceLocalOwner through to the handler', async () => { + const handler = vi.fn(() => Promise.resolve()) + const queue = createWorktreeChangeRefreshQueue(handler) + + queue.enqueue({ repoId: 'repo-1', forceLocalOwner: true }) + await flushPromises() + + expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: true }) + }) + + it('does not coalesce a local-pinned refresh into a runtime-routed one', async () => { + const firstRefresh = deferred() + const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined) + const queue = createWorktreeChangeRefreshQueue(handler) + + // First refresh starts draining immediately; the next two queue behind it. + // A plain refresh and a local-pinned refresh differ, so both are kept. + queue.enqueue({ repoId: 'repo-1', forceLocalOwner: false }) + queue.enqueue({ repoId: 'repo-1', forceLocalOwner: false }) + queue.enqueue({ repoId: 'repo-1', forceLocalOwner: true }) + + expect(handler).toHaveBeenCalledTimes(1) + + firstRefresh.resolve() + await flushPromises() + + expect(handler).toHaveBeenCalledTimes(3) + expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, { forceLocalOwner: false }) + expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, { forceLocalOwner: true }) }) it('drops queued trailing refreshes after disposal', async () => { diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.ts index ed095d2a98a..ef69da6dd05 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.ts @@ -6,12 +6,20 @@ type WorktreeRename = { type WorktreeChangeEvent = { repoId: string renamed?: WorktreeRename + // Why: set on local worktrees:changed while a remote runtime is active, so the + // refresh pins to the local host instead of dropping the event (see useIpcEvents). + forceLocalOwner?: boolean } -type WorktreeChangeRefreshHandler = (repoId: string, renamed?: WorktreeRename) => Promise +type WorktreeChangeRefreshHandler = ( + repoId: string, + renamed?: WorktreeRename, + options?: { forceLocalOwner?: boolean } +) => Promise type QueuedWorktreeChange = { renamed?: WorktreeRename + forceLocalOwner?: boolean } type RepoRefreshState = { @@ -36,7 +44,7 @@ export function createWorktreeChangeRefreshQueue( while (!disposed && state.queue.length > 0) { const next = state.queue.shift() try { - await handler(repoId, next?.renamed) + await handler(repoId, next?.renamed, { forceLocalOwner: next?.forceLocalOwner }) } catch (error) { console.error('Failed to refresh changed worktrees:', error) } @@ -68,13 +76,19 @@ export function createWorktreeChangeRefreshQueue( } if (event.renamed) { - state.queue.push({ renamed: event.renamed }) + state.queue.push({ renamed: event.renamed, forceLocalOwner: event.forceLocalOwner }) } else { const lastQueued = state.queue.at(-1) // Why: Windows/OneDrive can emit a burst for one checkout change. Keep a // trailing refresh, but do not fan out adjacent identical repo scans. - if (!lastQueued || lastQueued.renamed !== undefined) { - state.queue.push({}) + // A differing forceLocalOwner is not identical — keep it as its own scan + // so a local-pinned refresh is never coalesced into a runtime-routed one. + if ( + !lastQueued || + lastQueued.renamed !== undefined || + Boolean(lastQueued.forceLocalOwner) !== Boolean(event.forceLocalOwner) + ) { + state.queue.push({ forceLocalOwner: event.forceLocalOwner }) } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 55c763740e2..ccf38b5ed60 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -117,9 +117,12 @@ export type WorktreeSlice = { */ hasHydratedWorktreePurge: boolean fetchDetectedWorktrees: (repoId: string) => Promise - fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise + fetchWorktrees: ( + repoId: string, + options?: { requireAuthoritative?: boolean; forceLocalOwner?: boolean } + ) => Promise fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise - fetchWorktreeLineage: () => Promise + fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise updateWorktreeLineage: ( worktreeId: string, args: { parentWorktreeId?: string; noParent?: boolean } diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 57be252a49f..8516add5e80 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1222,6 +1222,82 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) + it('pins the list fetch to the local host when forceLocalOwner is set', async () => { + // Regression: a local `worktrees:changed` event for an unbound + // repo while a remote runtime is active must refresh against the local + // host, not the runtime — otherwise CLI-created local worktrees stay + // invisible in the sidebar until an app restart. + const store = createTestStore() + const local = makeWorktree({ + id: 'repo1::/local/wt1', + repoId: 'repo1', + path: '/local/wt1', + branch: 'refs/heads/local' + }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [local])) + + await store.getState().fetchWorktrees('repo1', { forceLocalOwner: true }) + + expect(store.getState().worktreesByRepo.repo1).toEqual([local]) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('pins a duplicate repo id to its local owner without replacing runtime worktrees', async () => { + const store = createTestStore() + const local = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo', + path: '/local/wt', + hostId: 'local' + }) + const remote = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + path: '/remote/wt', + hostId: 'runtime:env-1' + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [ + { + id: 'same-repo', + path: '/repos/local', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'local' + }, + { + id: 'same-repo', + path: '/repos/remote', + displayName: 'remote', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { 'same-repo': [remote] }, + detectedWorktreesByRepo: { + 'same-repo': makeDetectedResult('same-repo', [remote]) + } + } as Partial) + mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('same-repo', [local])) + + await store.getState().fetchWorktrees('same-repo', { forceLocalOwner: true }) + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) + expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: remote.id, hostId: 'runtime:env-1' }), + expect.objectContaining({ id: local.id, hostId: 'local' }) + ]) + ) + }) + it('fetches SSH repo worktrees through local IPC even when a runtime is focused', async () => { const store = createTestStore() const sshWorktree = makeWorktree({ @@ -1247,7 +1323,7 @@ describe('fetchWorktrees', () => { makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' }) ) - await store.getState().fetchWorktrees('repo-ssh') + await store.getState().fetchWorktrees('repo-ssh', { forceLocalOwner: true }) expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -1286,11 +1362,12 @@ describe('fetchWorktrees', () => { _meta: { runtimeId: 'runtime-remote' } }) - await store.getState().fetchWorktrees('repo-remote') + await store.getState().fetchWorktrees('repo-remote', { forceLocalOwner: true }) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ { ...remote, hostId: 'runtime:env-1' } ]) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) it('stamps runtime worktrees with the owning project host setup', async () => { @@ -1794,6 +1871,22 @@ describe('worktree lineage state', () => { expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) }) + it('pins lineage refresh to the local host when forceLocalOwner is set', async () => { + const store = createTestStore() + const lineage = makeLineage() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: {} + } as Partial) + mockApi.worktrees.listLineage.mockResolvedValue({ [lineage.worktreeId]: lineage }) + + await store.getState().fetchWorktreeLineage({ forceLocalOwner: true }) + + expect(mockApi.worktrees.listLineage).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) + }) + it('updates lineage through the active remote runtime environment', async () => { const store = createTestStore() const lineage = makeLineage() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 689fd546282..6fb6c293c23 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2330,10 +2330,22 @@ export const createWorktreeSlice: StateCreator try { const ownerState = get() const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] - const hostId = repoHostId(ownerState, repoId) - const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) + const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) + const hasLocalOwner = repoOwners.some( + (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ) + // Why: a local event may share its repo id with the focused runtime; prefer + // the local owner without redirecting runtime/SSH-only repos. + const useLocalOwner = + options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0) + const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId) + const ownerWasMissingAtStart = repoOwners.length === 0 const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) - const settings = settingsForRepoOwner(ownerState, repoId, hostId) + const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId) + const settings = + useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId + ? { ...ownerSettings, activeRuntimeEnvironmentId: null } + : ownerSettings const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { executionHostId: hostId, requireAuthoritative: options?.requireAuthoritative @@ -2693,10 +2705,17 @@ export const createWorktreeSlice: StateCreator set({ hasHydratedWorktreePurge: true }) }, - fetchWorktreeLineage: async () => { + fetchWorktreeLineage: async (options) => { try { // Why: lineage is a focused-host refresh; host-merge so other hosts' fetched lineage is preserved. - await refreshWorktreeLineageForSettings(get().settings, set, { + const ownerSettings = get().settings + // Why: local worktree-change events while a runtime is focused are paired + // with a forced-local list refresh; lineage must follow the same owner. + const settings = + options?.forceLocalOwner && ownerSettings?.activeRuntimeEnvironmentId + ? { ...ownerSettings, activeRuntimeEnvironmentId: null } + : ownerSettings + await refreshWorktreeLineageForSettings(settings, set, { reuseRecentCompatibilityFailure: true }) } catch (err) { diff --git a/tests/e2e/local-worktree-visibility-runtime-active.spec.ts b/tests/e2e/local-worktree-visibility-runtime-active.spec.ts new file mode 100644 index 00000000000..79ba2f8ae16 --- /dev/null +++ b/tests/e2e/local-worktree-visibility-runtime-active.spec.ts @@ -0,0 +1,79 @@ +/** + * Regression: a worktree created via the CLI (`orca worktree + * create`) must appear in the sidebar even while a remote runtime is active. + * + * The faithful trigger is the real CLI path — the RuntimeClient connects to the + * running app's socket and calls `worktree.create`, which registers a managed + * worktree and fires the `worktrees:changed` IPC the renderer listens for. + * Before the fix, the renderer dropped that IPC whenever a remote runtime was + * active (an unbound repo's list fetch would route to the runtime), so the + * worktree never appeared until an app restart. The "remote runtime active" + * condition is injected into the renderer store, so no real remote host is + * needed. + */ + +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime-client' + +test.describe('worktree visibility with a remote runtime active', () => { + test('a CLI-created worktree appears in the sidebar while a remote runtime is active', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const repoId = await orcaPage.evaluate(() => { + const repos = window.__store?.getState().repos ?? [] + // This case reproduces only for a local-host repo — one whose execution + // host resolves to local (executionHostId unset or 'local') and which has + // no connection binding. That is the repo whose list fetch an active + // runtime would otherwise route away from local. Select it explicitly so + // a future fixture change can't silently drop coverage. + const target = repos.find( + (repo) => (repo.executionHostId ?? 'local') === 'local' && !repo.connectionId + ) + if (!target) { + throw new Error('expected a seeded local-host repo') + } + return target.id + }) + + // The CLI talks to the running app over the socket recorded in its userData + // dir — exactly what `orca worktree create` does from a terminal. + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const createViaCli = async (name: string): Promise => { + const response = await client.call<{ worktree: { id: string } }>('worktree.create', { + repo: `id:${repoId}`, + name, + noParent: true, + activate: false + }) + return response.result.worktree.id + } + const worktreeRow = (worktreeId: string) => + orcaPage.locator(`[data-worktree-id=${JSON.stringify(worktreeId)}]`).first() + + // Guard: with no runtime active, a CLI-created worktree appears. This proves + // the create+notify path works, so the assertion below isolates the bug + // rather than masking a broken harness as a fixed regression. + const controlId = await createViaCli(`wt-control-${Date.now()}`) + await expect(worktreeRow(controlId)).toBeVisible({ timeout: 15_000 }) + + // Stage a remote runtime as active — the condition that triggered the drop. + await orcaPage.evaluate(() => { + window.__store?.setState((current) => ({ + settings: { ...current.settings, activeRuntimeEnvironmentId: 'e2e-fake-runtime' } + })) + }) + + // The fix: a CLI-created worktree must still appear, with no app restart. + const targetId = await createViaCli(`wt-runtime-active-${Date.now()}`) + await expect( + worktreeRow(targetId), + 'a CLI-created worktree must appear even while a remote runtime is active' + ).toBeVisible({ timeout: 15_000 }) + }) +}) From b63d4cde28d04ac45bc1de9ce5cb8894094ca479 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:15:25 -0700 Subject: [PATCH 25/29] feat(agent-status): question glyph for "needs you" state everywhere (#9996) * feat(agent-status): show question glyph for needs-you state everywhere Replace the amber attention dot with the dashboard's MessageCircleQuestion chat glyph for the waiting/permission "needs you" state across all surfaces: the agent dashboard cards, in-app dashboard rows, the sidebar's Agent Dashboard quick-indicator counts, the worktree-level status dot, and the shared agent-row/terminal-tab indicators. On the dashboard card the header glyph is suppressed when a question summary pill is present so "needs you" reads once, not twice. * test(agent-status): assert amber question glyph, not amber dot The needs-you unification replaced the amber dot with the amber MessageCircleQuestion glyph, so update the remaining state assertions in DashboardAgentRow, WorktreeCardStatusSlot, and TerminalTabLeadingIcon to match (lucide-message-circle-question + text-amber-500). * test(agent-status): cover needs-you glyph surfaces --- .../src/components/AgentStateDot.test.ts | 10 ++++--- src/renderer/src/components/AgentStateDot.tsx | 21 ++++++++++----- .../dashboard-popout/AgentKanbanCard.test.tsx | 24 +++++++++++++++++ .../dashboard-popout/AgentKanbanCard.tsx | 3 ++- .../dashboard/DashboardAgentRow.test.tsx | 5 ++-- .../components/sidebar/SidebarNav.test.tsx | 26 ++++++++++++++++++ .../src/components/sidebar/SidebarNav.tsx | 21 ++++++++++----- .../sidebar/StatusIndicator.test.ts | 10 ++++--- .../components/sidebar/StatusIndicator.tsx | 27 +++++++++++++------ .../sidebar/WorktreeCardStatusSlot.test.tsx | 6 +++-- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 5 ++-- 11 files changed, 123 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 40c2bea60f3..72fbc6223a6 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -47,12 +47,14 @@ describe('AgentStateDot', () => { }) it.each(['permission', 'waiting'] satisfies AgentDotState[])( - 'renders %s as an amber attention dot', + 'renders %s as an amber question glyph', (state) => { - const classNames = renderDotClassNames(state) + const markup = renderMarkup(state) - expect(classNames).toContain('bg-amber-500') - expect(classNames).not.toContain('bg-red-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(markup).toContain('text-amber-500') + expect(markup).not.toContain('bg-amber-500') + expect(markup).not.toContain('data-agent-spinner') } ) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index a5c50dad1fe..97d919dca65 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CircleCheck } from 'lucide-react' +import { CircleCheck, MessageCircleQuestion } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -95,6 +95,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } + if (state === 'permission' || state === 'waiting') { + return ( + + + ) + } + return ( diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index d338a21d381..ebcfee3d26b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -60,6 +60,30 @@ describe('AgentKanbanCard', () => { expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() }) + it('shows the question glyph once when a summary is available', () => { + const attentionCard = card({ + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Approve deploy?' + }) + const { container, rerender } = render( + + ) + + expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument() + expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1) + + rerender( + + ) + expect(screen.getByTestId('state-dot')).toBeInTheDocument() + expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() + }) + it('skips structured-clone rerenders until visible card data or its age changes', () => { const onOpenTerminal = vi.fn() const initial = card({ startedAt: 1_000 }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 2ae818b5dbd..e92dbcb0640 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -98,7 +98,8 @@ export const AgentKanbanCard = memo( > {card.worktreeName} - + {/* The summary pill already carries the attention glyph. */} + {card.askSummary ? null : } {card.lastUserMessage || card.lastAgentMessage ? ( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 931409f13ba..93238b8fc56 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -247,12 +247,13 @@ describe('DashboardAgentRow', () => { expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true) }) - it('renders waiting rows with the amber permission color', () => { + it('renders waiting rows with the amber question glyph', () => { const markup = renderRow(makeAgent({}, { state: 'waiting' })) const tokens = classTokens(markup) expect(markup).toContain('aria-label="Waiting for input"') - expect(tokens).toContain('bg-amber-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(tokens).toContain('text-amber-500') expect(tokens).not.toContain('bg-red-500') }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index a436942bdd3..42e7ee20f82 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ refreshPreflightStatus: vi.fn(), checkLinearConnection: vi.fn(), hasPairedMobileDevice: false, + agentBucketCounts: { attention: 0, working: 0, idle: 0 }, dismissMobileOnboardingBadge: vi.fn(), setSetupGuideSidebarDismissed: vi.fn() })) @@ -39,6 +40,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ useActivityUnreadCount: () => 0 })) +vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({ + useAgentBucketCounts: () => mocks.agentBucketCounts +})) + vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'J'], doubleTap: false }] })) @@ -203,6 +208,7 @@ describe('SidebarNav', () => { vi.clearAllMocks() await i18n.changeLanguage('en') mocks.hasPairedMobileDevice = false + mocks.agentBucketCounts = { attention: 0, working: 0, idle: 0 } setSidebarState() }) @@ -252,6 +258,26 @@ describe('SidebarNav', () => { expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() }) + it('uses a question glyph only for the Needs You count', async () => { + mocks.agentBucketCounts = { attention: 2, working: 3, idle: 4 } + setSidebarState({ + settings: { + ...getDefaultSettings('/tmp'), + experimentalAgentDashboardPopout: true + } + }) + const container = await renderSidebarNav() + + const attention = container.querySelector('[aria-label="Needs You: 2"]') + const working = container.querySelector('[aria-label="Working: 3"]') + const idle = container.querySelector('[aria-label="Idle: 4"]') + expect(attention?.querySelector('.lucide-message-circle-question-mark')).not.toBeNull() + expect(working?.querySelector('.rounded-full')).not.toBeNull() + expect(idle?.querySelector('.rounded-full')).not.toBeNull() + expect(working?.querySelector('svg')).toBeNull() + expect(idle?.querySelector('svg')).toBeNull() + }) + it('shows the Mobile entry by default for older settings', () => { expect(shouldShowMobileButton(null)).toBe(true) expect(shouldShowMobileButton({})).toBe(true) diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 0572150a56c..ccd04bf73b8 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,5 +1,13 @@ import React from 'react' -import { Bell, CalendarClock, EyeOff, LayoutDashboard, Search, Smartphone } from 'lucide-react' +import { + Bell, + CalendarClock, + EyeOff, + LayoutDashboard, + MessageCircleQuestion, + Search, + Smartphone +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -44,10 +52,7 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } -// Per-state dot colors mirror AgentStateDot so the sidebar counts read the same -// as the board (amber = needs you, yellow = working, neutral = idle, emerald = done). -const DASHBOARD_BUCKET_DOT_CLASS: Record = { - attention: 'bg-amber-500', +const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'idle', string> = { working: 'bg-yellow-500', idle: 'bg-neutral-500/50' } @@ -80,7 +85,11 @@ function DashboardBucketCounts({ aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`} className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55" > - + {bucket === 'attention' ? ( + + ) : ( + + )} {counts[bucket]} ))} diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 18ae2d1f154..871687ccbe1 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -32,11 +32,13 @@ describe('StatusIndicator', () => { expect(markup).not.toContain('animation:spin') }) - it('renders permission as an amber attention dot', () => { - const classNames = renderDotClassNames('permission') + it('renders permission as an amber question glyph', () => { + const markup = renderMarkup('permission') - expect(classNames).toContain('bg-amber-500') - expect(classNames).not.toContain('bg-red-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(markup).toContain('text-amber-500') + expect(markup).not.toContain('bg-amber-500') + expect(markup).not.toContain('data-agent-spinner') }) it('renders active as full emerald dot', () => { diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index ff4c4c25c89..7fffc9f9787 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { MessageCircleQuestion } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' @@ -39,6 +40,18 @@ const StatusIndicator = React.memo(function StatusIndicator({ ) } + if (status === 'permission') { + return ( + + + ) + } + return ( diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index d06efb037ba..8911c1783c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -126,7 +126,8 @@ describe('WorktreeCardStatusSlot', () => { ) expect(markup).toContain('Needs permission · Unread') - expect(markup).toContain('bg-amber-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(markup).toContain('text-amber-500') expect(markup).not.toContain('data-worktree-status-lane-unread=""') expect(markup).not.toContain('data-worktree-unread-alert=""') expect(markup).not.toContain('aria-label="Mark as read"') @@ -378,7 +379,8 @@ describe('WorktreeCardStatusSlot', () => { ) expect(markup).toContain('Needs permission') - expect(markup).toContain('bg-amber-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(markup).toContain('text-amber-500') expect(markup).not.toContain('PR checks: Failed') }) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx index 58d57669fa1..6c65ba56638 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -36,11 +36,12 @@ describe('TerminalTabLeadingIcon', () => { expect(markup).toContain('data-agent-icon="codex"') }) - it('shows a needs-input (permission) state as an amber dot', () => { + it('shows a needs-input (permission) state as an amber question glyph', () => { const markup = renderStatus('permission') expect(markup).toContain('data-agent-activity-status="permission"') - expect(markup).toContain('bg-amber-500') + expect(markup).toContain('lucide-message-circle-question-mark') + expect(markup).toContain('text-amber-500') expect(markup).not.toContain('bg-red-500') }) From b65c5939a21ef2b9eb890fabc73098826ef48af4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:21:51 -0700 Subject: [PATCH 26/29] perf(web): pause the runtime heartbeat while the window is hidden (#9883) * perf(web): pause runtime heartbeat while hidden Co-authored-by: Orca * fix(web): preserve inbound-liveness baseline across a hidden heartbeat re-arm The visible re-arm rebaselined lastInboundFrameAt=now, so a socket that went silent while the window was hidden looked freshly-heard-from and its death was masked for another full idle window (~25s). Move the fresh-connect baseline into startHeartbeat (the real 'we just connected' moment) and have the visible re-arm only reset the tick clock + clear an in-flight probe, preserving lastInboundFrameAt so the next visible tick probes a stale connection promptly and closes if unanswered. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../web/web-runtime-client-heartbeat.test.ts | 164 +++++++++++++++++- src/renderer/src/web/web-runtime-client.ts | 27 ++- 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts index d7657634f98..ab85698660b 100644 --- a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts +++ b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts @@ -2,11 +2,30 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { WebRuntimeClient } from './web-runtime-client' // Why: a half-open browser WebSocket stays readyState===OPEN with no -// onclose/onerror, so the client must actively detect server silence and force -// a reconnect. These tests drive the private heartbeat with controllable time + -// visibility (the real timers/visibility are faked away). +// onclose/onerror, so the client must actively detect server silence without +// keeping its timer armed while the window is hidden. const fakeSockets: FakeWebSocket[] = [] +let visibilityState: DocumentVisibilityState = 'visible' +let nextIntervalId = 1 +const documentListeners = new Map void>() +const intervalCallbacks = new Map void>() +const setIntervalMock = vi.fn((callback: () => void, _intervalMs: number): number => { + const intervalId = nextIntervalId++ + intervalCallbacks.set(intervalId, callback) + return intervalId +}) +const clearIntervalMock = vi.fn((intervalId: number): void => { + intervalCallbacks.delete(intervalId) +}) +const addDocumentEventListenerMock = vi.fn((event: string, listener: () => void): void => { + documentListeners.set(event, listener) +}) +const removeDocumentEventListenerMock = vi.fn((event: string, listener: () => void): void => { + if (documentListeners.get(event) === listener) { + documentListeners.delete(event) + } +}) class FakeWebSocket { static readonly CONNECTING = 0 @@ -34,6 +53,7 @@ type HeartbeatInternals = { lastInboundFrameAt: number lastHeartbeatTickAt: number heartbeatProbeSentAt: number | null + startHeartbeat: () => void runHeartbeatTick: () => void now: () => number isDocumentVisible: () => boolean @@ -47,7 +67,6 @@ function makeConnectedClient(): { setVisible: (visible: boolean) => void } { let nowMs = 1_000 - let visible = true const client = new WebRuntimeClient({ v: 2, endpoint: 'ws://127.0.0.1:6768', @@ -57,7 +76,7 @@ function makeConnectedClient(): { const internals = client as unknown as HeartbeatInternals // Override the protected time/visibility seams deterministically. internals.now = () => nowMs - internals.isDocumentVisible = () => visible + internals.isDocumentVisible = () => visibilityState === 'visible' const socket = fakeSockets[0]! socket.readyState = FakeWebSocket.OPEN internals.ws = socket @@ -73,8 +92,8 @@ function makeConnectedClient(): { setNow: (ms) => { nowMs = ms }, - setVisible: (next) => { - visible = next + setVisible: (visible) => { + visibilityState = visible ? 'visible' : 'hidden' } } } @@ -82,14 +101,31 @@ function makeConnectedClient(): { describe('WebRuntimeClient liveness heartbeat', () => { beforeEach(() => { fakeSockets.length = 0 + visibilityState = 'visible' + nextIntervalId = 1 + documentListeners.clear() + intervalCallbacks.clear() + setIntervalMock.mockClear() + clearIntervalMock.mockClear() + addDocumentEventListenerMock.mockClear() + removeDocumentEventListenerMock.mockClear() vi.stubGlobal('window', { setTimeout, clearTimeout, - setInterval, - clearInterval, + setInterval: setIntervalMock, + clearInterval: clearIntervalMock, atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') }) + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: addDocumentEventListenerMock, + removeEventListener: removeDocumentEventListenerMock + }) + vi.stubGlobal('setInterval', setIntervalMock) + vi.stubGlobal('clearInterval', clearIntervalMock) vi.stubGlobal('WebSocket', FakeWebSocket) }) @@ -97,6 +133,15 @@ describe('WebRuntimeClient liveness heartbeat', () => { vi.unstubAllGlobals() }) + function dispatchVisibilityChange(): void { + documentListeners.get('visibilitychange')?.() + } + + function runActiveHeartbeatTick(): void { + expect(intervalCallbacks.size).toBe(1) + intervalCallbacks.values().next().value?.() + } + // Advance time AND record a tick boundary so the suspended-loop detector // (sinceLastTick) sees a normal cadence, mirroring back-to-back real ticks. function advanceOneTick(internals: HeartbeatInternals, setNow: (ms: number) => void): void { @@ -104,6 +149,107 @@ describe('WebRuntimeClient liveness heartbeat', () => { setNow(next) } + it('disarms the heartbeat interval while hidden', () => { + const { client, internals, setVisible } = makeConnectedClient() + + internals.startHeartbeat() + expect(setIntervalMock).toHaveBeenCalledWith(expect.any(Function), 10_000) + expect(intervalCallbacks.size).toBe(1) + + setVisible(false) + dispatchVisibilityChange() + + expect(clearIntervalMock).toHaveBeenCalledTimes(1) + expect(intervalCallbacks.size).toBe(0) + client.close() + }) + + it('re-arms once and resets the tick clock but preserves the liveness baseline when visible again', () => { + const { client, internals, setNow, setVisible } = makeConnectedClient() + internals.startHeartbeat() + internals.heartbeatProbeSentAt = 1_000 + + setVisible(false) + dispatchVisibilityChange() + setNow(601_000) + setVisible(true) + dispatchVisibilityChange() + dispatchVisibilityChange() + + expect(setIntervalMock).toHaveBeenCalledTimes(2) + expect(intervalCallbacks.size).toBe(1) + // lastInboundFrameAt is NOT rebaselined on a visible re-arm: it stays at the fresh-connect baseline + // (1_000) so a socket that went silent while hidden is still detectable on the next tick. + expect(internals.lastInboundFrameAt).toBe(1_000) + // The tick clock resets so the parked hidden gap isn't misread as a suspended loop; the probe clears. + expect(internals.lastHeartbeatTickAt).toBe(601_000) + expect(internals.heartbeatProbeSentAt).toBeNull() + client.close() + }) + + it('probes and then closes a connection that went silent while hidden, once visible again', () => { + const { client, internals, socket, setNow, setVisible } = makeConnectedClient() + internals.startHeartbeat() + + // Hide, let a long silent gap elapse, then become visible again. The connection produced no inbound + // frames the whole time, so the preserved baseline (1_000) must drive prompt liveness detection. + setVisible(false) + dispatchVisibilityChange() + setNow(601_000) + setVisible(true) + dispatchVisibilityChange() + + // First visible tick: idle far exceeds the threshold, so send a liveness probe (not an immediate close). + setNow(611_000) + runActiveHeartbeatTick() + expect(socket.close).not.toHaveBeenCalled() + expect(socket.send).toHaveBeenCalledTimes(1) + expect(internals.heartbeatProbeSentAt).toBe(611_000) + + // Normal-cadence ticks: still within grace → no close yet. + setNow(621_000) + runActiveHeartbeatTick() + expect(socket.close).not.toHaveBeenCalled() + + // Probe now unanswered past grace (20s) → force the reconnect. + setNow(631_000) + runActiveHeartbeatTick() + expect(socket.close).toHaveBeenCalledTimes(1) + client.close() + }) + + it('keeps the visible heartbeat cadence unchanged', () => { + const { client, internals, socket, setNow } = makeConnectedClient() + internals.startHeartbeat() + + setNow(11_000) + runActiveHeartbeatTick() + setNow(21_000) + runActiveHeartbeatTick() + expect(socket.send).not.toHaveBeenCalled() + + setNow(31_000) + runActiveHeartbeatTick() + expect(socket.send).toHaveBeenCalledTimes(1) + expect(internals.heartbeatProbeSentAt).toBe(31_000) + client.close() + }) + + it('cleans up the heartbeat interval and visibility listener on close', () => { + const { client, internals } = makeConnectedClient() + internals.startHeartbeat() + const visibilityListener = documentListeners.get('visibilitychange') + + client.close() + + expect(intervalCallbacks.size).toBe(0) + expect(removeDocumentEventListenerMock).toHaveBeenCalledWith( + 'visibilitychange', + visibilityListener + ) + expect(documentListeners.has('visibilitychange')).toBe(false) + }) + it('does nothing while the socket keeps receiving frames', () => { const { internals, socket } = makeConnectedClient() // Just under the idle threshold → no probe, no close. diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 4f44fe2a811..70574350400 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -2,6 +2,7 @@ import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import type { WebPairingOffer } from './web-pairing' +import { installWindowVisibilityInterval } from '../lib/window-visibility-interval' import { withRemoteRuntimeTailscaleHint } from '../../../shared/remote-runtime-tailscale-hint' import { decrypt, @@ -76,7 +77,7 @@ export class WebRuntimeClient { private connectTimer: number | null = null private handshakeTimer: number | null = null private reconnectTimer: number | null = null - private heartbeatTimer: number | null = null + private heartbeatCleanup: (() => void) | null = null private lastInboundFrameAt = 0 // Timestamp of an outstanding liveness probe (null = none); dead-close fires only on an unanswered sent probe. private heartbeatProbeSentAt: number | null = null @@ -765,18 +766,32 @@ export class WebRuntimeClient { private startHeartbeat(): void { this.clearHeartbeatTimer() + // Why: this runs at 'connected', right after the handshake's inbound frames — a genuine liveness + // baseline. Only the fresh-connect moment resets lastInboundFrameAt; the visible re-arm below must not. const now = this.now() this.lastInboundFrameAt = now this.lastHeartbeatTickAt = now this.heartbeatProbeSentAt = null - this.heartbeatTimer = window.setInterval(() => this.runHeartbeatTick(), HEARTBEAT_INTERVAL_MS) + this.heartbeatCleanup = installWindowVisibilityInterval({ + run: () => this.runHeartbeatTick(), + runOnVisible: () => this.rebaselineHeartbeat(), + intervalMs: HEARTBEAT_INTERVAL_MS + }) + } + + private rebaselineHeartbeat(): void { + // Why: the interval is merely parked while hidden, so on becoming visible reset the tick clock (don't + // let the parked gap trip the suspended-loop rebaseline) and drop a probe that was in flight when we + // hid. But PRESERVE lastInboundFrameAt: if the socket went silent while hidden, keeping the real + // last-heard time lets the next tick detect the staleness and probe promptly, instead of masking a + // dead connection for another full idle window (#9883 review). + this.lastHeartbeatTickAt = this.now() + this.heartbeatProbeSentAt = null } private clearHeartbeatTimer(): void { - if (this.heartbeatTimer) { - window.clearInterval(this.heartbeatTimer) - this.heartbeatTimer = null - } + this.heartbeatCleanup?.() + this.heartbeatCleanup = null this.heartbeatProbeSentAt = null } From 5d3b968283ee7ade9898bad7ea0603fc3fd5f2fd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:21:54 -0700 Subject: [PATCH 27/29] perf(runtime): arm the WS-server heartbeat only while clients are connected (#9885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(runtime): idle websocket heartbeat without clients Co-authored-by: Orca * fix(runtime): probe immediately when the WS heartbeat arms Arming the heartbeat on the first accepted connection started a fresh interval, so the first liveness ping was a full interval (~15s) out — a socket that died right after connecting went unprobed for that window. Run one sweep synchronously in start() so the first ping goes out at arm time; the seeded socket is pinged (never reaped on the arm sweep) and reaped on the next tick only if it never pongs. Tests updated for the earlier first probe. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../remote-runtime-server-heartbeat.test.ts | 32 ++++++- .../rpc/remote-runtime-server-heartbeat.ts | 5 ++ src/main/runtime/rpc/ws-transport.test.ts | 87 +++++++++++++++++++ src/main/runtime/rpc/ws-transport.ts | 17 +++- 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts index c9739c344cb..a34b43c3c8b 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts @@ -7,20 +7,46 @@ afterEach(() => { }) describe('RemoteRuntimeServerHeartbeat', () => { + it('still reaps a client that misses a probe while another remains alive', async () => { + vi.useFakeTimers() + let now = 1_000 + const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket + const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + heartbeat.noteAlive(responsiveSocket) + heartbeat.noteAlive(deadSocket) + // start() probes immediately: both are pinged now (probe #1) and cleared to await a pong. + heartbeat.start(() => [responsiveSocket, deadSocket]) + // Only the responsive socket pongs the immediate probe. + heartbeat.noteAlive(responsiveSocket) + + now += 100 + await vi.advanceTimersByTimeAsync(100) + + expect(responsiveSocket.ping).toHaveBeenCalledTimes(2) + expect(responsiveSocket.terminate).not.toHaveBeenCalled() + expect(deadSocket.ping).toHaveBeenCalledTimes(1) + expect(deadSocket.terminate).toHaveBeenCalledTimes(1) + heartbeat.stop() + }) + it('grants clients a fresh probe after the server event loop resumes', async () => { vi.useFakeTimers() let now = 1_000 const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) heartbeat.noteAlive(socket) + // start() probes immediately (ping #1); the socket pongs it. heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) now += 100 - await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(100) // ping #2, socket pongs + heartbeat.noteAlive(socket) now += 3_600_000 - await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(100) // resumed-from-pause: re-grants a probe (ping #3), no reap - expect(socket.ping).toHaveBeenCalledTimes(2) + expect(socket.ping).toHaveBeenCalledTimes(3) expect(socket.terminate).not.toHaveBeenCalled() now += 100 diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index e8ff47e901d..3b5bb9760df 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -22,6 +22,11 @@ export class RemoteRuntimeServerHeartbeat { this.lastTickAt = this.now() this.timer = setInterval(() => this.sweep(getClients()), this.intervalMs) this.timer.unref?.() + // Why: the interval's first tick is a full intervalMs (~15s) out, so arming on the first accepted + // connection would leave that socket unprobed for the whole window. Sweep once now so the first + // liveness ping goes out immediately; seeded-alive sockets are pinged (not reaped) and have until + // the next tick to pong. WS pong is answered at the protocol level, so a live socket always survives. + this.sweep(getClients()) } stop(): void { diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 5c02194a835..d7fef1428e9 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -14,6 +15,30 @@ function makeTls() { return loadOrCreateTlsCertificate(userDataPath) } +function heartbeatLifecycle(transport: WebSocketTransport) { + return transport as unknown as { + heartbeat: { + timer: ReturnType | null + alive: WeakSet + } + heartbeatConnections: Set + wss: { clients: Set } + handleConnection(ws: WebSocket): void + } +} + +async function waitForHeartbeatLifecycle( + transport: WebSocketTransport, + connectionCount: number, + armed: boolean +): Promise { + await vi.waitFor(() => { + const lifecycle = heartbeatLifecycle(transport) + expect(lifecycle.heartbeatConnections.size).toBe(connectionCount) + expect(lifecycle.heartbeat.timer !== null).toBe(armed) + }) +} + describe('WebSocketTransport', () => { const transports: WebSocketTransport[] = [] @@ -70,6 +95,68 @@ describe('WebSocketTransport', () => { await transport.stop() }) + it('arms heartbeat only while accepted connections exist', async () => { + const { transport } = await createTransport() + await transport.start() + + const lifecycle = heartbeatLifecycle(transport) + expect(lifecycle.heartbeat.timer).toBeNull() + expect(lifecycle.heartbeatConnections.size).toBe(0) + + const firstClient = await connectWs(transport) + await waitForHeartbeatLifecycle(transport, 1, true) + const firstServerSocket = Array.from(lifecycle.wss.clients)[0] + expect(firstServerSocket).toBeDefined() + // Note: arming probes immediately, so `alive` membership is racy here (the client's protocol-level + // pong re-adds the socket right after the arm sweep clears it). Assert the arm/disarm lifecycle only. + const firstTimer = lifecycle.heartbeat.timer + + const secondClient = await connectWs(transport) + await waitForHeartbeatLifecycle(transport, 2, true) + expect(lifecycle.heartbeat.timer).toBe(firstTimer) + + firstClient.close() + await waitForHeartbeatLifecycle(transport, 1, true) + expect(lifecycle.heartbeat.timer).toBe(firstTimer) + + secondClient.close() + await waitForHeartbeatLifecycle(transport, 0, false) + + const thirdClient = await connectWs(transport) + await waitForHeartbeatLifecycle(transport, 1, true) + expect(lifecycle.heartbeat.timer).not.toBe(firstTimer) + + thirdClient.close() + await waitForHeartbeatLifecycle(transport, 0, false) + }) + + it('finalizes heartbeat membership once when error and close race', () => { + const transport = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(transport) + const lifecycle = heartbeatLifecycle(transport) + const socket = Object.assign(new EventEmitter(), { + OPEN: WebSocket.OPEN, + readyState: WebSocket.OPEN, + close: vi.fn(), + ping: vi.fn(), + terminate: vi.fn() + }) as unknown as WebSocket + const closeHandler = vi.fn() + transport.onConnectionClose(closeHandler) + + lifecycle.handleConnection(socket) + expect(lifecycle.heartbeatConnections.size).toBe(1) + expect(lifecycle.heartbeat.timer).not.toBeNull() + + socket.emit('error', new Error('connection reset')) + socket.emit('close') + + expect(closeHandler).toHaveBeenCalledTimes(1) + expect(socket.close).toHaveBeenCalledTimes(1) + expect(lifecycle.heartbeatConnections.size).toBe(0) + expect(lifecycle.heartbeat.timer).toBeNull() + }) + it('handles request/response round-trip', async () => { const { transport } = await createTransport((msg, reply) => { const request = JSON.parse(msg) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 54a3e85f1ad..6933657b59b 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -61,6 +61,7 @@ export class WebSocketTransport implements RpcTransport { | null = null // Why: maps each socket to its authenticated clientId so close can report which device disconnected. private wsClientIds = new Map() + private heartbeatConnections = new Set() private preAuthTimers = new WeakMap>() constructor({ @@ -199,7 +200,6 @@ export class WebSocketTransport implements RpcTransport { this.httpServer = httpServer this.wss = wss - this.heartbeat.start(() => this.wss?.clients ?? []) } // Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. @@ -217,6 +217,7 @@ export class WebSocketTransport implements RpcTransport { this.wss = null this.httpServer = null this.heartbeat.stop() + this.heartbeatConnections.clear() if (wss) { for (const client of wss.clients) { @@ -280,6 +281,10 @@ export class WebSocketTransport implements RpcTransport { ws.off('close', finalizeConnection) ws.off('error', onError) this.clearPreAuthTimer(ws) + this.heartbeatConnections.delete(ws) + if (this.heartbeatConnections.size === 0) { + this.heartbeat.stop() + } const clientId = this.wsClientIds.get(ws) ?? null this.wsClientIds.delete(ws) const hasOtherConnections = @@ -287,6 +292,13 @@ export class WebSocketTransport implements RpcTransport { this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) } + // Why: seed before arming so a fresh first socket survives its initial sweep. + this.heartbeatConnections.add(ws) + this.heartbeat.noteAlive(ws) + if (this.heartbeatConnections.size === 1) { + this.heartbeat.start(() => this.wss?.clients ?? []) + } + const preAuthTimer = setTimeout(() => { if (!this.wsClientIds.has(ws)) { // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. @@ -298,9 +310,6 @@ export class WebSocketTransport implements RpcTransport { } this.preAuthTimers.set(ws, preAuthTimer) - // Why: seed alive so the first heartbeat tick doesn't reap a fresh socket before its first pong. - this.heartbeat.noteAlive(ws) - ws.on('pong', onPong) ws.on('message', onMessage) From 6a43f9935d731662425846350ac4814aadf78c48 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:21:58 -0700 Subject: [PATCH 28/29] perf(mobile): coalesce duplicate concurrent home-screen requests (#9888) * perf(mobile): coalesce overlapping home requests Co-authored-by: Orca * fix(mobile): queue a trailing follow-up for triggers during an in-flight read Single-flight returned the in-flight promise to any trigger that arrived mid-read, so a distinct refresh requested while a slow read was on the wire was silently answered by the older response and never re-read the latest state (UI could stay one refresh cycle stale). Coalesce mid-flight triggers into exactly one trailing follow-up (latest params win) whose fresh result is delivered to those callers. Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/app/index.tsx | 23 ++-- .../transport/request-single-flight.test.ts | 127 ++++++++++++++++++ mobile/src/transport/request-single-flight.ts | 110 +++++++++++++++ 3 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 mobile/src/transport/request-single-flight.test.ts create mode 100644 mobile/src/transport/request-single-flight.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index a8909c372b7..b7132849845 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -28,6 +28,7 @@ import { loadHosts } from '../src/transport/host-store' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' +import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useAllHostClients, useCloseHost, @@ -140,11 +141,11 @@ function clientKey(client: RpcClient): number { function fetchStats( client: RpcClient, + hostId: string, setStats: (s: StatsSummary) => void, disposed: () => boolean ) { - client - .sendRequest('stats.summary') + sendSingleFlightRequest(client, hostId, 'stats.summary') .then((response) => { if (disposed()) { return @@ -182,9 +183,8 @@ function fetchWorktreeInfo( }) } - client - // Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate. - .sendRequest('worktree.ps', { limit: 10000 }) + // Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate. + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 }) .then((response) => { if (disposed()) { return @@ -225,8 +225,7 @@ function fetchAccountsSnapshot( ) => void, disposed: () => boolean ) { - client - .sendRequest('accounts.list') + sendSingleFlightRequest(client, hostId, 'accounts.list') .then((response) => { if (disposed()) { return @@ -248,9 +247,9 @@ function fetchTaskProviders( disposed: () => boolean ) { Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('preflight.check'), - client.sendRequest('linear.status') + sendSingleFlightRequest(client, hostId, 'settings.get'), + sendSingleFlightRequest(client, hostId, 'preflight.check'), + sendSingleFlightRequest(client, hostId, 'linear.status') ]) .then(([settingsResponse, preflightResponse, linearResponse]) => { if (disposed()) { @@ -405,7 +404,7 @@ export default function HomeScreen() { }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { - fetchStats(entry.client, setStats, () => stale) + fetchStats(entry.client, entry.hostId, setStats, () => stale) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) @@ -512,7 +511,7 @@ export default function HomeScreen() { } if (!statsFetched) { statsFetched = true - fetchStats(entry.client, setStats, () => false) + fetchStats(entry.client, entry.hostId, setStats, () => false) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) } diff --git a/mobile/src/transport/request-single-flight.test.ts b/mobile/src/transport/request-single-flight.test.ts new file mode 100644 index 00000000000..218cc12d10f --- /dev/null +++ b/mobile/src/transport/request-single-flight.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import type { RpcResponse } from './types' +import { sendSingleFlightRequest } from './request-single-flight' + +function makeResponse(id: string): RpcResponse { + return { id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } } +} + +const response = makeResponse('request-1') + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function rpcClient(sendRequest: RpcClient['sendRequest']): RpcClient { + return { sendRequest } as RpcClient +} + +describe('sendSingleFlightRequest', () => { + it('coalesces triggers that arrive during an in-flight read into one trailing follow-up', async () => { + const leading = deferred() + const trailing = deferred() + const leadingResponse = makeResponse('leading') + const trailingResponse = makeResponse('trailing') + const sendRequest = vi + .fn<() => Promise>() + .mockReturnValueOnce(leading.promise) + .mockReturnValueOnce(trailing.promise) + const client = rpcClient(sendRequest) + + const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 }) + // Two more triggers arrive while the read is on the wire: no duplicate now, and they share ONE + // trailing follow-up (not the older in-flight response). + const second = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 }) + const third = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 }) + + expect(second).toBe(third) + expect(second).not.toBe(first) + expect(sendRequest).toHaveBeenCalledTimes(1) + + leading.resolve(leadingResponse) + expect(await first).toBe(leadingResponse) + + // The leading read settled → the coalesced follow-up fires exactly once. + expect(sendRequest).toHaveBeenCalledTimes(2) + trailing.resolve(trailingResponse) + expect(await second).toBe(trailingResponse) + expect(await third).toBe(trailingResponse) + }) + + it('starts a fresh request once nothing is in flight', async () => { + const leading = deferred() + const sendRequest = vi + .fn<() => Promise>() + .mockReturnValueOnce(leading.promise) + .mockResolvedValueOnce(response) + const client = rpcClient(sendRequest) + + const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 }) + leading.resolve(response) + await first + + const next = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 }) + expect(next).not.toBe(first) + expect(sendRequest).toHaveBeenCalledTimes(2) + await next + }) + + it('rejects the leading caller on failure but still runs a queued follow-up', async () => { + const leading = deferred() + const failure = new Error('request failed') + const sendRequest = vi + .fn<() => Promise>() + .mockReturnValueOnce(leading.promise) + .mockResolvedValueOnce(response) + const client = rpcClient(sendRequest) + + const first = sendSingleFlightRequest(client, 'host-1', 'accounts.list') + const second = sendSingleFlightRequest(client, 'host-1', 'accounts.list') + + leading.reject(failure) + await expect(first).rejects.toBe(failure) + // A trigger that arrived mid-flight is not poisoned by the leading failure: its follow-up runs. + await expect(second).resolves.toBe(response) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('clears a failed leading request so the next call retries', async () => { + const failure = new Error('request failed') + const sendRequest = vi + .fn<() => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(response) + const client = rpcClient(sendRequest) + + await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).rejects.toBe(failure) + await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).resolves.toBe(response) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('does not share requests across clients, hosts, or request kinds', async () => { + const pending = deferred() + const firstSend = vi.fn(() => pending.promise) + const secondSend = vi.fn(() => pending.promise) + const firstClient = rpcClient(firstSend) + const secondClient = rpcClient(secondSend) + + const requests = [ + sendSingleFlightRequest(firstClient, 'host-1', 'settings.get'), + sendSingleFlightRequest(firstClient, 'host-2', 'settings.get'), + sendSingleFlightRequest(firstClient, 'host-1', 'preflight.check'), + sendSingleFlightRequest(secondClient, 'host-1', 'settings.get') + ] + + expect(firstSend).toHaveBeenCalledTimes(3) + expect(secondSend).toHaveBeenCalledTimes(1) + pending.resolve(response) + await Promise.all(requests) + }) +}) diff --git a/mobile/src/transport/request-single-flight.ts b/mobile/src/transport/request-single-flight.ts new file mode 100644 index 00000000000..c538e7ed755 --- /dev/null +++ b/mobile/src/transport/request-single-flight.ts @@ -0,0 +1,110 @@ +import type { RpcClient } from './rpc-client' +import type { RpcResponse } from './types' + +type Deferred = { + promise: Promise + resolve: (value: RpcResponse) => void + reject: (reason?: unknown) => void +} + +type SingleFlightEntry = { + // The request currently on the wire for this (client, host, kind). + current: Promise + // At most one trailing follow-up: every trigger that arrives while `current` is in flight coalesces + // here so a refresh requested mid-read still re-reads the latest state (latest params win) instead of + // being silently answered by the older in-flight response. + followUp: { deferred: Deferred; params: unknown } | null +} + +const inFlightRequests = new WeakMap>>() + +function makeDeferred(): Deferred { + let resolve!: (value: RpcResponse) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +export function sendSingleFlightRequest( + client: RpcClient, + hostId: string, + requestKind: string, + params?: unknown +): Promise { + let requestsByHost = inFlightRequests.get(client) + if (!requestsByHost) { + requestsByHost = new Map() + inFlightRequests.set(client, requestsByHost) + } + let requestsByKind = requestsByHost.get(hostId) + if (!requestsByKind) { + requestsByKind = new Map() + requestsByHost.set(hostId, requestsByKind) + } + + const send = (): Promise => { + try { + return client.sendRequest(requestKind, params) + } catch (error) { + return Promise.reject(error) + } + } + + const existing = requestsByKind.get(requestKind) + if (existing) { + // A read is already on the wire: don't fire a duplicate now, but don't drop this trigger either. + // Record (or refresh) a single trailing follow-up that runs once the current read settles. + if (existing.followUp) { + existing.followUp.params = params + } else { + existing.followUp = { deferred: makeDeferred(), params } + } + return existing.followUp.deferred.promise + } + + const entry: SingleFlightEntry = { current: send(), followUp: null } + requestsByKind.set(requestKind, entry) + + const cleanup = (): void => { + if (requestsByKind.get(requestKind) !== entry) { + return + } + requestsByKind.delete(requestKind) + if (requestsByKind.size === 0) { + requestsByHost.delete(hostId) + } + if (requestsByHost.size === 0) { + inFlightRequests.delete(client) + } + } + + // Chain each settled request into either its queued follow-up (delivering the fresh result to every + // caller that awaited it) or entry teardown. Recurses so triggers arriving during a follow-up queue + // the next one. + const onSettled = (): void => { + const followUp = entry.followUp + if (!followUp) { + cleanup() + return + } + entry.followUp = null + let next: Promise + try { + next = client.sendRequest(requestKind, followUp.params) + } catch (error) { + next = Promise.reject(error) + } + entry.current = next + next.then( + (response) => followUp.deferred.resolve(response), + (error) => followUp.deferred.reject(error) + ) + void next.then(onSettled, onSettled) + } + + void entry.current.then(onSettled, onSettled) + return entry.current +} From 01bcc57ff68065c866bec0fec5ba3a4bd9a85455 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:22:01 -0700 Subject: [PATCH 29/29] perf(mobile): gate dictation setup progress polling on foreground + single-flight (#9892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): gate dictation setup polling Co-authored-by: Orca * fix(mobile): fence a stale dictation refresh against a newer setPolling intent An in-flight setup read resolving 'keep polling' after an explicit setPolling(false) wrote polling=true and rescheduled, resurrecting a poll the caller had just stopped. Snapshot a pollingRevision when each read starts and only apply its result if no explicit setPolling superseded it mid-flight — so a late true can't restart a stopped poll (nor a late false cancel a restart). Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/app/voice-settings.tsx | 68 +++--- .../components/MobileDictationSetupSheet.tsx | 45 ++-- .../dictation-setup-poll-controller.test.ts | 197 ++++++++++++++++++ .../dictation-setup-poll-controller.ts | 153 ++++++++++++++ .../dictation/use-dictation-setup-poller.ts | 54 +++++ 5 files changed, 459 insertions(+), 58 deletions(-) create mode 100644 mobile/src/dictation/dictation-setup-poll-controller.test.ts create mode 100644 mobile/src/dictation/dictation-setup-poll-controller.ts create mode 100644 mobile/src/dictation/use-dictation-setup-poller.ts diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a6c725c0e1c..4a0f6d07c79 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ActivityIndicator, Pressable, @@ -9,7 +9,7 @@ import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' +import { useFocusEffect, useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' @@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' +import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller' import { deleteDictationModel, downloadDictationModel, @@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element { const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const pollRef = useRef | null>(null) + const [routeFocused, setRouteFocused] = useState(false) - const refresh = useCallback(async () => { + useFocusEffect( + useCallback(() => { + setRouteFocused(true) + return () => setRouteFocused(false) + }, []) + ) + + const refresh = useCallback(async (): Promise => { if (!client) { - return + return false } try { - setSetup(await fetchDictationSetup(client)) + const next = await fetchDictationSetup(client) + setSetup(next) setError(null) + return next.models.some(isModelInFlight) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load voice settings') + return undefined + } finally { + setLoading(false) } }, [client]) - // Initial load once a connected client is available. - useEffect(() => { - if (!client) { - return - } - setLoading(true) - setError(null) - void refresh().finally(() => setLoading(false)) - }, [client, refresh]) + const polling = setup?.models.some(isModelInFlight) ?? false + const refreshSetup = useDictationSetupPoller({ + visible: routeFocused && client !== null, + polling, + refresh, + intervalMs: POLL_INTERVAL_MS + }) - // Poll only while a model is downloading/extracting; stop otherwise. useEffect(() => { - const inFlight = setup?.models.some(isModelInFlight) ?? false - if (inFlight && client) { - pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS) - return () => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - } + if (routeFocused && client && setup === null) { + setLoading(true) } - return undefined - }, [setup, client, refresh]) + }, [routeFocused, client, setup]) const handleToggleEnabled = useCallback( async (enabled: boolean) => { @@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element { setSetup(await setDictationConfig(client, { enabled })) } catch (err) { setError(err instanceof Error ? err.message : 'Could not update') - void refresh() + void refreshSetup() } }, - [client, refresh] + [client, refreshSetup] ) const handleSelectMode = useCallback( @@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element { setSetup(await setDictationConfig(client, { dictationMode })) } catch (err) { setError(err instanceof Error ? err.message : 'Could not update') - void refresh() + void refreshSetup() } }, - [client, refresh] + [client, refreshSetup] ) const handleUseModel = useCallback( @@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element { setError(null) try { await downloadDictationModel(client, model.id) - await refresh() + await refreshSetup() } catch (err) { setError(err instanceof Error ? err.message : 'Download failed') } finally { setBusyAction(null) } }, - [client, refresh] + [client, refreshSetup] ) const handleDelete = useCallback( diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx index 7e8e221aab2..f4a81c02b8f 100644 --- a/mobile/src/components/MobileDictationSetupSheet.tsx +++ b/mobile/src/components/MobileDictationSetupSheet.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native' import { Check, Download } from 'lucide-react-native' import { BottomDrawer } from './BottomDrawer' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { RpcClient } from '../transport/rpc-client' import { triggerError, triggerSuccess } from '../platform/haptics' +import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller' import { downloadDictationModel, fetchDictationSetup, @@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: const [setup, setSetup] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(null) - const pollRef = useRef | null>(null) - - const refresh = useCallback(async () => { + const refresh = useCallback(async (): Promise => { if (!client) { - return + return false } try { - setSetup(await fetchDictationSetup(client)) + const next = await fetchDictationSetup(client) + setSetup(next) + setError(null) + return next.models.some(isModelInFlight) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load') + return undefined } }, [client]) + const polling = setup?.models.some(isModelInFlight) ?? false + const refreshSetup = useDictationSetupPoller({ + visible: visible && client !== null, + polling, + refresh, + intervalMs: POLL_INTERVAL_MS + }) + useEffect(() => { if (visible) { setError(null) - void refresh() } - }, [visible, refresh]) - - // Poll only while something is downloading/extracting; stop otherwise. - useEffect(() => { - const inFlight = setup?.models.some(isModelInFlight) ?? false - if (visible && inFlight && client) { - pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS) - return () => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - } - } - return undefined - }, [visible, setup, client, refresh]) + }, [visible]) const handleDownload = useCallback( async (model: MobileSpeechModel) => { @@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: setError(null) try { await downloadDictationModel(client, model.id) - await refresh() + await refreshSetup() } catch (err) { triggerError() setError(err instanceof Error ? err.message : 'Download failed') @@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: setBusy(null) } }, - [client, refresh] + [client, refreshSetup] ) const handleUseModel = useCallback( diff --git a/mobile/src/dictation/dictation-setup-poll-controller.test.ts b/mobile/src/dictation/dictation-setup-poll-controller.test.ts new file mode 100644 index 00000000000..5ca010759d6 --- /dev/null +++ b/mobile/src/dictation/dictation-setup-poll-controller.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DictationSetupPollController } from './dictation-setup-poll-controller' + +const POLL_INTERVAL_MS = 1500 + +async function flushPromises(): Promise { + // Why: the refresh mock wraps its result in `.finally()` and the resume path chains + // runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask + // hops deep — drain generously rather than a fixed two ticks. + for (let i = 0; i < 8; i += 1) { + await Promise.resolve() + } +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + return { + promise: new Promise((next) => { + resolve = next + }), + resolve + } +} + +describe('DictationSetupPollController', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not refresh while hidden, unfocused, or backgrounded', async () => { + const refresh = vi.fn().mockResolvedValue(true) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + + poller.setForeground(true) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + expect(refresh).not.toHaveBeenCalled() + + poller.setVisible(true) + expect(refresh).toHaveBeenCalledOnce() + await flushPromises() + poller.setVisible(false) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + expect(refresh).toHaveBeenCalledOnce() + + poller.setVisible(true) + expect(refresh).toHaveBeenCalledTimes(2) + await flushPromises() + poller.setForeground(false) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + expect(refresh).toHaveBeenCalledTimes(2) + + poller.dispose() + }) + + it('keeps slow refreshes single-flight and waits a full delay after each response', async () => { + const requests = [deferred(), deferred(), deferred()] + let active = 0 + let maxActive = 0 + const refresh = vi.fn(() => { + const request = requests[refresh.mock.calls.length - 1] + active += 1 + maxActive = Math.max(maxActive, active) + return request.promise.finally(() => { + active -= 1 + }) + }) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + poller.setVisible(true) + poller.setForeground(true) + + expect(refresh).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4) + expect(refresh).toHaveBeenCalledOnce() + + requests[0].resolve(true) + await flushPromises() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(refresh).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4) + expect(refresh).toHaveBeenCalledTimes(2) + + requests[1].resolve(true) + await flushPromises() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1) + expect(refresh).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(refresh).toHaveBeenCalledTimes(3) + expect(maxActive).toBe(1) + + requests[2].resolve(false) + await flushPromises() + poller.dispose() + }) + + it('coalesces an immediate resume refresh behind a slow request', async () => { + const requests = [deferred(), deferred()] + let active = 0 + let maxActive = 0 + const refresh = vi.fn(() => { + const request = requests[refresh.mock.calls.length - 1] + active += 1 + maxActive = Math.max(maxActive, active) + return request.promise.finally(() => { + active -= 1 + }) + }) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + poller.setVisible(true) + poller.setForeground(true) + + poller.setForeground(false) + poller.setForeground(true) + expect(refresh).toHaveBeenCalledOnce() + + requests[0].resolve(true) + await flushPromises() + expect(refresh).toHaveBeenCalledTimes(2) + expect(maxActive).toBe(1) + + requests[1].resolve(false) + await flushPromises() + poller.dispose() + }) + + it('refreshes immediately when visibility or foreground eligibility resumes', async () => { + const refresh = vi.fn().mockResolvedValue(true) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + poller.setVisible(true) + poller.setForeground(true) + expect(refresh).toHaveBeenCalledOnce() + await flushPromises() + + poller.setForeground(false) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + poller.setForeground(true) + expect(refresh).toHaveBeenCalledTimes(2) + await flushPromises() + + poller.setVisible(false) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + poller.setVisible(true) + expect(refresh).toHaveBeenCalledTimes(3) + + poller.dispose() + }) + + it('stops after setup leaves the download or extraction lifecycle', async () => { + const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + poller.setVisible(true) + poller.setForeground(true) + await flushPromises() + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(refresh).toHaveBeenCalledTimes(2) + await flushPromises() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4) + expect(refresh).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + + poller.dispose() + }) + + it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => { + const request = deferred() + const refresh = vi.fn(() => request.promise) + const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) + poller.setPolling(true) + poller.setVisible(true) + poller.setForeground(true) + expect(refresh).toHaveBeenCalledOnce() + + // Explicit stop lands while the read is still on the wire. + poller.setPolling(false) + // The stale read then resolves "keep polling" — the fence must drop it, not restart the poll. + request.resolve(true) + await flushPromises() + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4) + expect(refresh).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + + poller.dispose() + }) +}) diff --git a/mobile/src/dictation/dictation-setup-poll-controller.ts b/mobile/src/dictation/dictation-setup-poll-controller.ts new file mode 100644 index 00000000000..cb927f161f9 --- /dev/null +++ b/mobile/src/dictation/dictation-setup-poll-controller.ts @@ -0,0 +1,153 @@ +type PollState = { + visible: boolean + foreground: boolean + polling: boolean +} + +type RefreshResult = boolean | undefined + +export class DictationSetupPollController { + private state: PollState = { visible: false, foreground: false, polling: false } + private timer: ReturnType | null = null + private inFlight = false + private immediateRefreshPending = false + private refreshWaiters: Array<() => void> = [] + private disposed = false + // Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire. + // Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be + // fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll). + private pollingRevision = 0 + + constructor( + private readonly refresh: () => Promise, + private readonly intervalMs: number + ) {} + + setVisible(visible: boolean): void { + this.update({ visible }) + } + + setForeground(foreground: boolean): void { + this.update({ foreground }) + } + + setPolling(polling: boolean): void { + this.pollingRevision += 1 + this.update({ polling }) + } + + refreshNow(): Promise { + if (this.disposed || !this.isEligible()) { + return Promise.resolve() + } + return new Promise((resolve) => { + this.refreshWaiters.push(resolve) + this.requestRefresh(true) + }) + } + + dispose(): void { + this.disposed = true + this.immediateRefreshPending = false + this.clearTimer() + this.resolveRefreshWaiters() + } + + private update(next: Partial): void { + if (this.disposed) { + return + } + const wasEligible = this.isEligible() + const wasPolling = this.state.polling + this.state = { ...this.state, ...next } + + if (!this.isEligible()) { + this.immediateRefreshPending = false + this.clearTimer() + return + } + if (!wasEligible) { + this.requestRefresh(true) + return + } + if (!this.state.polling) { + this.clearTimer() + return + } + if (!wasPolling) { + this.scheduleRefresh() + } + } + + private isEligible(): boolean { + return this.state.visible && this.state.foreground + } + + private requestRefresh(immediate: boolean): void { + if (this.inFlight) { + this.immediateRefreshPending ||= immediate + return + } + this.clearTimer() + this.inFlight = true + void this.runRefresh() + } + + private async runRefresh(): Promise { + // Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes + // its result stale. + const revisionAtStart = this.pollingRevision + let shouldContinue: RefreshResult + try { + shouldContinue = await this.refresh() + } catch { + // A transient read failure preserves the current lifecycle for a later retry. + shouldContinue = undefined + } finally { + this.inFlight = false + } + + // Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a + // late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart). + if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) { + this.state.polling = shouldContinue + } + if (this.disposed || !this.isEligible()) { + this.resolveRefreshWaiters() + return + } + if (this.immediateRefreshPending) { + this.immediateRefreshPending = false + this.requestRefresh(true) + return + } + this.resolveRefreshWaiters() + if (this.state.polling) { + this.scheduleRefresh() + } + } + + private scheduleRefresh(): void { + if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) { + return + } + this.timer = setTimeout(() => { + this.timer = null + this.requestRefresh(false) + }, this.intervalMs) + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } + + private resolveRefreshWaiters(): void { + const waiters = this.refreshWaiters.splice(0) + for (const resolve of waiters) { + resolve() + } + } +} diff --git a/mobile/src/dictation/use-dictation-setup-poller.ts b/mobile/src/dictation/use-dictation-setup-poller.ts new file mode 100644 index 00000000000..407eded9f6f --- /dev/null +++ b/mobile/src/dictation/use-dictation-setup-poller.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { AppState } from 'react-native' +import { DictationSetupPollController } from './dictation-setup-poll-controller' + +type PollerOptions = { + visible: boolean + polling: boolean + refresh: () => Promise + intervalMs: number +} + +export function useDictationSetupPoller({ + visible, + polling, + refresh, + intervalMs +}: PollerOptions): () => Promise { + const refreshRef = useRef(refresh) + refreshRef.current = refresh + const poller = useMemo( + () => new DictationSetupPollController(() => refreshRef.current(), intervalMs), + [intervalMs] + ) + + useEffect(() => () => poller.dispose(), [poller]) + + useEffect(() => { + void poller.refreshNow() + }, [poller, refresh]) + + useEffect(() => { + poller.setPolling(polling) + }, [poller, polling]) + + useEffect(() => { + poller.setVisible(visible) + if (!visible) { + poller.setForeground(false) + return undefined + } + + poller.setForeground(AppState.currentState === 'active') + const subscription = AppState.addEventListener('change', (state) => { + poller.setForeground(state === 'active') + }) + return () => { + subscription.remove() + poller.setVisible(false) + poller.setForeground(false) + } + }, [poller, visible]) + + return useCallback(() => poller.refreshNow(), [poller]) +}