diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 54354fc83..d295d8e6c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7020,6 +7020,270 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: created.tab.id, + parentTabId: created.tab.parentTabId, + leafId: created.tab.leafId, + ptyId: 'serve-mobile-pty', + status: 'ready' + }) + ]) + }) + + it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:split-siblings', + snapshotVersion: 1, + activeGroupId: 'headless-group', + activeTabId: 'host-tab::pane:2', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'headless-group', + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + } + ], + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'left', + isActive: false + }, + { + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2', + title: 'right', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2' + }) + ]) + expect(listed.activeTabId).toBe('host-tab::pane:2') + }) + + it('keeps preserved headless mobile session publication epochs idempotent', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:stable-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'host-tab::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const firstMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch) + expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1) + }) + + it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => { + const focusTerminal = vi.fn() + const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-persisted-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Mobile Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-persisted-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: `host-tab::${HEADLESS_LEAF_ID}`, + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId: 'serve-persisted-pty', + status: 'pending-handle' + }) + ]) + expect(listed.tabGroups?.[0]).toMatchObject({ + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + }) + + const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'serve-persisted-pty', + persistHostSessionBinding: true, + worktreeId: TEST_WORKTREE_ID + }) + ) + expect(focusTerminal).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready' + }) + }) + it('hydrates legacy persisted terminal tabs without layout entries', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aede15959..fe85fe4f0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -163,6 +163,7 @@ import type { RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabGroup, + RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, @@ -2127,9 +2128,13 @@ export class OrcaRuntimeService { private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, - options: { force?: boolean } = {} + options: { + force?: boolean + allowAttachedWindow?: boolean + onlyServeOwnedTerminals?: boolean + } = {} ): void { - if (this.getAvailableAuthoritativeWindow()) { + if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return } const session = this.store?.getWorkspaceSession?.() @@ -2142,35 +2147,150 @@ export class OrcaRuntimeService { : Object.entries(session.tabsByWorktree ?? {}) for (const [entryWorktreeId, persistedTabs] of entries) { const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) - if (existing && existing.tabs.length > 0 && options.force !== true) { + if ( + existing && + existing.tabs.length > 0 && + options.force !== true && + options.onlyServeOwnedTerminals !== true + ) { continue } - const tabs = this.buildHeadlessMobileSessionTerminalTabs(entryWorktreeId, persistedTabs) + const tabs = this.buildHeadlessMobileSessionTerminalTabs( + entryWorktreeId, + persistedTabs + ).filter( + (tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab) + ) if (tabs.length === 0) { continue } const activeTab = this.pickHeadlessActiveTerminalTab(tabs) const tabOrder = this.collectHeadlessParentTabOrder(tabs) const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) + const mergedTabs = + options.onlyServeOwnedTerminals === true && existing + ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) + : tabs + const mergedActiveTab = + existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? + activeTab ?? + (mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null) + const mergedTerminalTabs = mergedTabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) this.mobileSessionTabsByWorktree.set(entryWorktreeId, { - worktree: entryWorktreeId, + worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: groupId, - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab ? 'terminal' : null, - tabGroups: [ - { - id: groupId, - activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, - tabOrder - } - ], - tabs + activeGroupId: existing?.activeGroupId ?? groupId, + activeTabId: mergedActiveTab?.id ?? null, + activeTabType: mergedActiveTab?.type ?? null, + tabGroups: + options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ) + : [ + { + id: groupId, + activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, + tabOrder + } + ], + tabs: mergedTabs }) } } + private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith('serve-') + } + + private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { + if (this.isServeOwnedPtyId(tab.ptyId)) { + return true + } + return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => + this.isServeOwnedPtyId(ptyId) + ) + } + + private mergeMobileSessionSnapshotTabs( + baseTabs: readonly RuntimeMobileSessionSnapshotTab[], + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): RuntimeMobileSessionSnapshotTab[] { + const seenIds = new Set() + const merged: RuntimeMobileSessionSnapshotTab[] = [] + const add = (tab: RuntimeMobileSessionSnapshotTab): void => { + const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) + if (ids.some((id) => seenIds.has(id))) { + return + } + for (const id of ids) { + seenIds.add(id) + } + merged.push(tab) + } + for (const tab of baseTabs) { + add(tab) + } + for (const tab of extraTabs) { + add(tab) + } + return merged + } + + private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { + if (tab.type === 'terminal') { + // Why: split terminal leaves share one parent tab; merge dedup must stay + // leaf-scoped or preserved siblings collapse into a single surface. + return [tab.id, `${tab.parentTabId}::${tab.leafId}`] + } + if (tab.type === 'browser') { + return [tab.id, tab.browserWorkspaceId] + } + return [tab.id] + } + + private mergeMobileSessionTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + terminalTabs: readonly RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ): RuntimeMobileSessionTabGroup[] { + const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) + if (parentTabOrder.length === 0) { + return [...groups] + } + const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) + const nextGroups = + groups.length > 0 + ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + : [ + { + id: targetGroupId, + activeTabId: null, + tabOrder: [] + } + ] + const target = nextGroups[0]! + for (const tabId of parentTabOrder) { + if (!target.tabOrder.includes(tabId)) { + target.tabOrder.push(tabId) + } + } + const activeParentId = + activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null + target.activeTabId = + activeParentId && target.tabOrder.includes(activeParentId) + ? activeParentId + : (target.tabOrder[0] ?? null) + return nextGroups + } + private buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[] @@ -2468,11 +2588,14 @@ export class OrcaRuntimeService { const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find( (candidate) => candidate.type === 'terminal' && candidate.id === tab.id ) - if ( + // Why: serve-created tabs can be visible before any renderer has adopted + // their tab id, so focusing the renderer would silently no-op. + const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && - !this.notifier?.focusTerminal - ) { + (!this.notifier?.focusTerminal || + this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) + if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined try { await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, { @@ -2511,6 +2634,16 @@ export class OrcaRuntimeService { return this.getMobileSessionTabsForWorktree(worktreeId) } + private shouldMaterializeHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -12287,26 +12420,177 @@ export class OrcaRuntimeService { if (snapshots === undefined) { return } + // Why: renderer graphs are authoritative for renderer tabs, but headless + // serve terminals never enter that graph unless we preserve their bindings. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { + allowAttachedWindow: true, + onlyServeOwnedTerminals: true + }) const nextWorktrees = new Set() for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) if ( !existing || - snapshot.publicationEpoch !== existing.publicationEpoch || - snapshot.snapshotVersion >= existing.snapshotVersion + nextSnapshot.publicationEpoch !== existing.publicationEpoch || + nextSnapshot.snapshotVersion >= existing.snapshotVersion ) { - this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) + this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot) } } - for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) { + for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.notifyMobileSessionTabsRemoved(worktreeId) + const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) + if (preserved) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + nextWorktrees.add(worktreeId) + } else { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) + } } } } + private mergePreservedHeadlessMobileSessionTabs( + snapshot: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): RuntimeMobileSessionTabsSnapshot { + if (!existing) { + return snapshot + } + const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) + if (preservedTabs.length === 0) { + return snapshot + } + const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) + const normalizedPreservedTabs = preservedTabs.map((tab) => + hasIncomingActiveTab ? { ...tab, isActive: false } : tab + ) + const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) + if (tabs.length === snapshot.tabs.length) { + return snapshot + } + const activeTab = + snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...snapshot, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch( + snapshot, + normalizedPreservedTabs + ), + snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), + activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private buildPreservedHeadlessMobileSessionSnapshot( + existing: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot | null { + const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) + if (tabs.length === 0) { + return null + } + const activeTab = + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...existing, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + activeGroupId: + existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + existing.worktree, + existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private collectPreservedHeadlessMobileSessionTabs( + existing: RuntimeMobileSessionTabsSnapshot, + incoming?: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionSnapshotTab[] { + const incomingIds = new Set( + incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] + ) + return existing.tabs.filter((tab) => { + if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { + return false + } + return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + }) + } + + private shouldPreserveHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionSnapshotTab + ): boolean { + if (tab.type !== 'terminal') { + return false + } + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + + private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { + return ( + publicationEpoch.startsWith('headless:') || + publicationEpoch.startsWith('headless-hydrated:') || + publicationEpoch.includes(':headless-merge:') + ) + } + + private getMergedMobileSessionPublicationEpoch( + snapshot: RuntimeMobileSessionTabsSnapshot, + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string { + // Why: preserved snapshots can be merged repeatedly; normalize the prior + // merge suffix before recomputing so the publication epoch is idempotent. + const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] + const signature = createHash('sha1') + .update( + preservedTabs + .map((tab) => + tab.type === 'terminal' + ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` + : tab.id + ) + .join('|') + ) + .digest('hex') + .slice(0, 12) + return `${normalizedPublicationEpoch}:headless-merge:${signature}` + } + private notifyMobileSessionTabsRemoved(worktreeId: string): void { const removed: RuntimeMobileSessionTabsRemovedResult = { worktree: worktreeId,