diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index f758c569188..37782a205aa 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -104,6 +104,17 @@ function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } +function getWorktreeLabel(name: string | undefined, worktreeId: string): string { + if (name?.trim()) { + return name.trim() + } + const pathPart = worktreeId.includes('::') + ? worktreeId.slice(worktreeId.indexOf('::') + 2) + : worktreeId + const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' +} + export default function MobileFileExplorerScreen() { const { hostId, worktreeId, name } = useLocalSearchParams<{ hostId: string @@ -118,6 +129,7 @@ export default function MobileFileExplorerScreen() { const [error, setError] = useState(null) const [openingPath, setOpeningPath] = useState(null) const [truncated, setTruncated] = useState(false) + const worktreeLabel = getWorktreeLabel(name, worktreeId) const loadFiles = useCallback(async () => { if (!client || connState !== 'connected') { @@ -260,7 +272,7 @@ export default function MobileFileExplorerScreen() { Files - {name || 'Active worktree'} + {worktreeLabel} {truncated ? ' - Showing first 5000' : ''} diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b8991fe8963..eeccbaf5798 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -68,7 +68,10 @@ type MobileSessionTab = type: 'terminal' id: string title: string - terminal: string + parentTabId?: string + leafId?: string + status?: 'pending-handle' | 'ready' + terminal: string | null isActive: boolean } | { @@ -94,6 +97,7 @@ type MobileSessionTab = type SessionTabsResult = { worktree: string + publicationEpoch?: string snapshotVersion: number tabs: MobileSessionTab[] activeTabId: string | null @@ -127,34 +131,6 @@ type DirtyMarkdownDraft = { content: string } -function mergeTerminalFallbackTabs( - tabs: MobileSessionTab[], - terminals: Terminal[], - activeHandle: string | null -): MobileSessionTab[] { - const terminalTabs = tabs.filter( - (tab): tab is Extract => tab.type === 'terminal' - ) - if (terminals.length === 0) { - return tabs - } - const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.terminal, tab])) - const terminalHandles = new Set(terminals.map((terminal) => terminal.handle)) - const orderedTerminalTabs = terminals.map( - (terminal) => - terminalTabsByHandle.get(terminal.handle) ?? { - type: 'terminal' as const, - id: terminal.handle, - title: terminal.title, - terminal: terminal.handle, - isActive: terminal.handle === activeHandle - } - ) - const sessionOnlyTerminalTabs = terminalTabs.filter((tab) => !terminalHandles.has(tab.terminal)) - const nonTerminalTabs = tabs.filter((tab) => tab.type !== 'terminal') - return [...orderedTerminalTabs, ...sessionOnlyTerminalTabs, ...nonTerminalTabs] -} - function mergeTerminalRecordsByCurrentOrder( terminalTabs: Terminal[], currentTerminals: Terminal[] @@ -186,10 +162,7 @@ function getActiveTabIdForHandle( } type TerminalCreateResult = { - terminal: { - handle: string - title: string | null - } + tab: Extract } type MobileDisplayMode = 'auto' | 'phone' | 'desktop' @@ -893,8 +866,6 @@ export default function SessionScreen() { } } lastKnownTerminalCountRef.current = result.terminals.length - const current = activeHandleRef.current - // Why: defense-in-depth dedupe. If the server ever returns a list // with the same handle twice (race during rename/split, or stale // process tracking), React would throw 'two children with same @@ -909,23 +880,9 @@ export default function SessionScreen() { setTerminals(deduped) terminalsRef.current = deduped - setTerminalsLoaded(true) - if (activeSessionTabTypeRef.current !== 'terminal') { - return - } - - if (!current || !result.terminals.some((t) => t.handle === current)) { - const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0] - if (active) { - activeHandleRef.current = active.handle - setActiveHandle(active.handle) - subscribeToTerminal(active.handle) - } else { - activeHandleRef.current = null - setActiveHandle(null) - } - } + // Session tabs are the UI authority. terminal.list only refreshes + // per-handle metadata for existing ready terminal surfaces. } } catch { // Failed to list terminals @@ -959,15 +916,12 @@ export default function SessionScreen() { nextTabs = [...orphanedDraftTabs, ...nextTabs] } setSessionTabs(nextTabs) - const terminalTabs = nextTabs - .filter( - (tab): tab is Extract => tab.type === 'terminal' - ) - .map((tab) => ({ - handle: tab.terminal, - title: tab.title || 'Terminal', - isActive: tab.isActive - })) + const terminalTabs = nextTabs.flatMap((tab): Terminal[] => { + if (tab.type !== 'terminal' || typeof tab.terminal !== 'string') { + return [] + } + return [{ handle: tab.terminal, title: tab.title || 'Terminal', isActive: tab.isActive }] + }) const mergedTerminalsForActive = mergeTerminalRecordsByCurrentOrder( terminalTabs, terminalsRef.current @@ -978,9 +932,7 @@ export default function SessionScreen() { lastKnownTerminalCountRef.current, terminalTabs.length ) - if (nextTabs.length > 0) { - setTerminalsLoaded(true) - } + setTerminalsLoaded(true) const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current @@ -1028,24 +980,19 @@ export default function SessionScreen() { pendingActiveTerminalHandleRef.current = null } } - const currentHandle = activeHandleRef.current - if ( - currentHandle && - activeSessionTabTypeRef.current === 'terminal' && - !nextTabs.some((tab) => tab.type === 'terminal' && tab.terminal === currentHandle) && - mergedTerminalsForActive.some((terminal) => terminal.handle === currentHandle) - ) { - // Why: the renderer session snapshot can be partial while a new - // worktree is still creating its setup/agent terminals. Keep the - // terminal.list-backed active PTY visible until the snapshot catches up. - setActiveSessionTabId(getActiveTabIdForHandle(nextTabs, currentHandle)) - setActiveHandle(currentHandle) - subscribeToTerminal(currentHandle) - return - } activeSessionTabTypeRef.current = active?.type ?? null setActiveSessionTabId(active?.id ?? null) if (active?.type === 'terminal') { + if (typeof active.terminal !== 'string') { + const previous = activeHandleRef.current + if (previous) { + unsubscribeTerminal(previous) + initializedHandlesRef.current.delete(previous) + } + activeHandleRef.current = null + setActiveHandle(null) + return + } const previous = activeHandleRef.current if (previous && previous !== active.terminal) { unsubscribeTerminal(previous) @@ -1609,7 +1556,30 @@ export default function SessionScreen() { const switchSessionTab = useCallback( (tab: MobileSessionTab) => { if (tab.type === 'terminal') { - switchTab(tab.terminal) + if (typeof tab.terminal === 'string') { + switchTab(tab.terminal) + return + } + triggerSelection() + pendingActiveSessionTabIdRef.current = tab.id + pendingActiveTerminalHandleRef.current = null + activeSessionTabTypeRef.current = 'terminal' + setActiveSessionTabId(tab.id) + const prev = activeHandleRef.current + if (prev) { + unsubscribeTerminal(prev) + initializedHandlesRef.current.delete(prev) + } + activeHandleRef.current = null + setActiveHandle(null) + if (client) { + void client + .sendRequest('session.tabs.activate', { + worktree: `id:${worktreeId}`, + tabId: tab.id + }) + .catch(() => {}) + } return } @@ -1959,12 +1929,13 @@ export default function SessionScreen() { setCreateError('') try { - const response = await client.sendRequest('terminal.create', { - worktree: `id:${worktreeId}` + const response = await client.sendRequest('session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + afterTabId: activeSessionTabId ?? undefined }) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult - const created = result.terminal + const created = result.tab // Why: unsubscribe the old active terminal so the server restores its // desktop dims. Without this, the old terminal's mobile subscription // stays alive and its restore timer is never set. @@ -1973,26 +1944,37 @@ export default function SessionScreen() { unsubscribeTerminal(prev) initializedHandlesRef.current.delete(prev) } - activeHandleRef.current = created.handle - setActiveHandle(created.handle) - setTerminals((prev) => { - // Why: guard against duplicates if a parallel fetchTerminals() - // already inserted this handle. Without this, React throws - // 'two children with the same key' when both the optimistic - // insert and a canonical refetch race during creation. - if (prev.some((t) => t.handle === created.handle)) { - terminalsRef.current = prev + pendingActiveSessionTabIdRef.current = created.id + activeSessionTabTypeRef.current = 'terminal' + setActiveSessionTabId(created.id) + setSessionTabs((prev) => { + if (prev.some((tab) => tab.id === created.id)) { return prev } - const next = [ - ...prev, - { handle: created.handle, title: created.title || 'Terminal', isActive: true } - ] - terminalsRef.current = next - return next + return [...prev, { ...created, isActive: true }] }) - subscribeToTerminal(created.handle) - setTimeout(() => void fetchTerminals(), 500) + if (typeof created.terminal === 'string') { + const createdHandle = created.terminal + activeHandleRef.current = createdHandle + setActiveHandle(createdHandle) + setTerminals((prev) => { + if (prev.some((t) => t.handle === createdHandle)) { + terminalsRef.current = prev + return prev + } + const next = [ + ...prev, + { handle: createdHandle, title: created.title || 'Terminal', isActive: true } + ] + terminalsRef.current = next + return next + }) + subscribeToTerminal(createdHandle) + } else { + activeHandleRef.current = null + setActiveHandle(null) + } + setTimeout(() => void fetchSessionTabs(), 500) } else { setCreateError('Failed to create terminal') } @@ -2061,24 +2043,46 @@ export default function SessionScreen() { } } + async function handleCloseSessionTab(tab: MobileSessionTab) { + if (!client) return + try { + const response = await client.sendRequest('session.tabs.close', { + worktree: `id:${worktreeId}`, + tabId: tab.id + }) + if (response.ok) { + if (tab.type === 'terminal' && typeof tab.terminal === 'string') { + unsubscribeTerminal(tab.terminal) + terminalRefs.current.delete(tab.terminal) + initializedHandlesRef.current.delete(tab.terminal) + } + setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + if (activeSessionTabId === tab.id) { + activeSessionTabTypeRef.current = null + setActiveSessionTabId(null) + activeHandleRef.current = null + setActiveHandle(null) + } + setTimeout(() => void fetchSessionTabs(), 300) + } + } catch { + // Close failed — keep the authoritative session snapshot visible. + } + } + const isPhoneMode = (handle: string | null): boolean => { if (!handle) return false const mode = terminalModes.get(handle) return mode === 'auto' || mode === 'phone' || mode === undefined } - const visibleTabs: MobileSessionTab[] = - sessionTabs.length > 0 - ? mergeTerminalFallbackTabs(sessionTabs, terminals, activeHandle) - : terminals.map((terminal) => ({ - type: 'terminal' as const, - id: terminal.handle, - title: terminal.title, - terminal: terminal.handle, - isActive: terminal.handle === activeHandle - })) + const visibleTabs: MobileSessionTab[] = sessionTabs const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null + const activePendingTerminalTab = + activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string' + ? activeSessionTab + : null const showLoadingState = connState === 'connected' && !terminalsLoaded && visibleTabs.length === 0 const showEmptyState = connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle @@ -2169,16 +2173,14 @@ export default function SessionScreen() { {visibleTabs.map((t) => ( switchSessionTab(t)} onLongPress={() => { triggerMediumImpact() if (t.type === 'terminal') { + if (typeof t.terminal !== 'string') { + return + } setActionTarget({ handle: t.terminal, title: t.title, @@ -2202,9 +2204,7 @@ export default function SessionScreen() { @@ -2282,6 +2282,13 @@ export default function SessionScreen() { )} + ) : activePendingTerminalTab ? ( + + + + {activePendingTerminalTab.title || 'Loading terminal'} + + ) : ( { + const target = markdownActionTarget + setMarkdownActionTarget(null) + if (target) { + void handleCloseSessionTab(target) + } + } } ]} onClose={() => setMarkdownActionTarget(null)} @@ -2568,6 +2586,17 @@ export default function SessionScreen() { void readFileTab(target) } } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const target = fileActionTarget + setFileActionTarget(null) + if (target) { + void handleCloseSessionTab(target) + } + } } ]} onClose={() => setFileActionTarget(null)} diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index 368976676f1..7fa19a91b3b 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -1,10 +1,11 @@ -import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { Linking, Platform, Pressable, StyleSheet, Text, View } from 'react-native' import { router } from 'expo-router' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { CompatVerdict } from '../transport/protocol-compat' import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version' const RELEASES_URL = 'https://github.com/stablyai/orca/releases' +const IOS_APP_STORE_URL = 'itms-apps://apps.apple.com/app/orca-ide/id6766130217' type Props = { verdict: Extract @@ -12,10 +13,19 @@ type Props = { export function ProtocolBlockScreen({ verdict }: Props) { const isMobileTooOld = verdict.reason === 'mobile-too-old' + 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' } + const primaryAction = isMobileTooOld + ? mobileUpdateTarget.url && mobileUpdateTarget.label + ? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url } + : null + : { label: 'Open GitHub Releases', url: RELEASES_URL } const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop' const body = isMobileTooOld - ? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.` + ? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from ${mobileUpdateTarget.storeName} to continue.` : `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.` return ( @@ -23,16 +33,16 @@ export function ProtocolBlockScreen({ verdict }: Props) { {title} {body} - {/* Why: only desktop side has a deep-link target today — - App Store ID isn't published yet, so mobile-too-old is text-only. */} - {!isMobileTooOld ? ( + {/* 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(RELEASES_URL) + void Linking.openURL(primaryAction.url) }} > - Open GitHub Releases + {primaryAction.label} ) : null} { expect(read.tail).toEqual(['after unavailable']) }) + it('keeps mobile terminal surfaces visible while their leaf handle is pending', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Terminal 1', + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + status: 'pending-handle', + terminal: null + }) + ]) + }) + + it('resolves mobile terminal surfaces by exact split leaf', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + activeLeafId: 'pane:2', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'left' + }, + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:2', + paneRuntimeId: 2, + ptyId: 'pty-2', + paneTitle: 'right' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:2', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Terminal 1', + isActive: false + }, + { + type: 'terminal', + id: 'tab-1::pane:2', + parentTabId: 'tab-1', + leafId: 'pane:2', + title: 'Terminal 1', + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs).toHaveLength(2) + expect(result.tabs).toEqual([ + expect.objectContaining({ id: 'tab-1::pane:1', title: 'left', status: 'ready' }), + expect.objectContaining({ id: 'tab-1::pane:2', title: 'right', status: 'ready' }) + ]) + const [left, right] = result.tabs + expect(left?.type).toBe('terminal') + expect(right?.type).toBe('terminal') + if (left?.type === 'terminal' && right?.type === 'terminal') { + expect(left.terminal).not.toBe(right.terminal) + } + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new OrchestrationDb(':memory:') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 965fb64e43b..8de0369b637 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -51,8 +51,10 @@ import type { RuntimeSyncedTab, RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult, + RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionClientTab, RuntimeMobileSessionMarkdownTab, + RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, RuntimeFileListResult, @@ -297,8 +299,9 @@ type RuntimeNotifier = { opts: { direction: 'horizontal' | 'vertical'; command?: string } ): void renameTerminal(tabId: string, title: string | null): void - focusTerminal(tabId: string, worktreeId: string): void + focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void focusEditorTab?(tabId: string, worktreeId: string): void + closeSessionTab?(tabId: string, worktreeId: string): void openFile?(worktreeId: string, filePath: string, relativePath: string): void readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise saveMobileMarkdownTab?( @@ -779,7 +782,7 @@ export class OrcaRuntimeService { } this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) - this.syncMobileSessionTabs(graph.mobileSessionTabs ?? []) + this.syncMobileSessionTabs(graph.mobileSessionTabs) const nextLeaves = new Map() // Why: renderer reloads can briefly republish the same leaf with no ptyId; @@ -896,13 +899,30 @@ export class OrcaRuntimeService { } if (tab.type === 'terminal') { - this.notifier?.focusTerminal(tab.terminalTabId, worktreeId) + this.notifier?.focusTerminal(tab.parentTabId, worktreeId, tab.leafId) } else { this.notifier?.focusEditorTab?.(tab.id, worktreeId) } return this.getMobileSessionTabsForWorktree(worktreeId) } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { + const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + const worktreeId = + explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find((candidate) => candidate.id === tabId) + if (!tab) { + throw new Error('tab_not_found') + } + if (tab.type === 'terminal') { + this.notifier?.closeTerminal(tab.parentTabId) + } else { + this.notifier?.closeSessionTab?.(tab.id, worktreeId) + } + return { closed: true } + } + async readMobileMarkdownTab( worktreeSelector: string, tabId: string @@ -4314,6 +4334,117 @@ export class OrcaRuntimeService { return { handle, worktreeId: worktreeId ?? '', title: reply.title } } + async createMobileSessionTerminal( + worktreeSelector: string, + opts: { afterTabId?: string; activate?: boolean } = {} + ): Promise { + this.assertGraphReady() + const worktreeId = (await this.resolveWorktreeSelector(worktreeSelector)).id + let afterDesktopTabId: string | undefined + if (opts.afterTabId) { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) + if (!anchor) { + throw new Error('after_tab_not_found') + } + afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id + } + + const win = this.getAuthoritativeWindow() + const requestId = randomUUID() + const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal creation timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + r: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (r.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + if (r.error) { + reject(new Error(r.error)) + } else { + resolve({ tabId: r.tabId!, title: r.title ?? '' }) + } + } + ipcMain.on('terminal:tabCreateReply', handler) + win.webContents.send('terminal:requestTabCreate', { + requestId, + worktreeId, + afterTabId: afterDesktopTabId + }) + }) + + if (opts.activate !== false) { + this.notifier?.focusTerminal(reply.tabId, worktreeId, 'pane:1') + } + return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId) + } + + private waitForMobileTerminalSurface( + worktreeId: string, + parentTabId: string, + timeoutMs = 10_000 + ): Promise { + const existing = this.findMobileTerminalSurface(worktreeId, parentTabId) + if (existing) { + return Promise.resolve(existing) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + reject(new Error('Timed out waiting for terminal surface after creation')) + }, timeoutMs) + + const check = (): void => { + const next = this.findMobileTerminalSurface(worktreeId, parentTabId) + if (!next) { + return + } + clearTimeout(timer) + const idx = this.graphSyncCallbacks.indexOf(check) + if (idx !== -1) { + this.graphSyncCallbacks.splice(idx, 1) + } + resolve(next) + } + this.graphSyncCallbacks.push(check) + check() + }) + } + + private findMobileTerminalSurface( + worktreeId: string, + parentTabId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return null + } + const result = this.toMobileSessionTabsResult(snapshot) + const tab = result.tabs.find( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + ) + if (!tab || tab.type !== 'terminal') { + return null + } + return { + tab, + publicationEpoch: result.publicationEpoch, + snapshotVersion: result.snapshotVersion + } + } + private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { const existing = this.resolveHandleForTab(tabId) if (existing) { @@ -4883,22 +5014,46 @@ export class OrcaRuntimeService { } } - private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): void { + private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined): void { + if (snapshots === undefined) { + return + } const nextWorktrees = new Set() for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) - if (!existing || snapshot.snapshotVersion >= existing.snapshotVersion) { + if ( + !existing || + snapshot.publicationEpoch !== existing.publicationEpoch || + snapshot.snapshotVersion >= existing.snapshotVersion + ) { this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) } } for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) { if (!nextWorktrees.has(worktreeId)) { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) } } } + private notifyMobileSessionTabsRemoved(worktreeId: string): void { + const removed: RuntimeMobileSessionTabsRemovedResult = { + worktree: worktreeId, + publicationEpoch: `removed:${Date.now().toString(36)}`, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + for (const listener of this.mobileSessionTabListeners) { + listener(removed) + } + } + private notifyMobileSessionTabSnapshots(): void { if (this.mobileSessionTabListeners.size === 0) { return @@ -4916,6 +5071,7 @@ export class OrcaRuntimeService { if (!snapshot) { return { worktree: worktreeId, + publicationEpoch: 'none', snapshotVersion: 0, activeGroupId: null, activeTabId: null, @@ -4953,26 +5109,24 @@ export class OrcaRuntimeService { tabs.push(tab) continue } - const syncedTab = this.tabs.get(tab.terminalTabId) - const activeLeafId = syncedTab?.activeLeafId ?? null - const leaf = - activeLeafId != null - ? this.leaves.get(this.getLeafKey(tab.terminalTabId, activeLeafId)) - : null - if (!leaf) { - continue - } + const syncedTab = this.tabs.get(tab.parentTabId) + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null tabs.push({ type: 'terminal', id: tab.id, - title: syncedTab?.title ?? tab.title, - terminal: this.issueHandle(leaf), - isActive: tab.isActive + parentTabId: tab.parentTabId, + leafId: tab.leafId, + title: leaf?.paneTitle ?? syncedTab?.title ?? tab.title, + isActive: tab.isActive, + ...(leaf + ? { status: 'ready' as const, terminal: this.issueHandle(leaf) } + : { status: 'pending-handle' as const, terminal: null }) }) } const active = tabs.find((tab) => tab.isActive) ?? null return { worktree: snapshot.worktree, + publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion, activeGroupId: snapshot.activeGroupId, activeTabId: active?.id ?? null, diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 20c0d99ada1..0f3dc335e6d 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -15,6 +15,7 @@ describe('session tab RPC methods', () => { getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue({ worktree: 'wt-1', + publicationEpoch: 'test', snapshotVersion: 1, activeGroupId: null, activeTabId: null, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 9c2b4102dca..b5362d4df49 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -15,6 +15,11 @@ const ActivateTab = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')) }) +const CreateTerminalTab = WorktreeTabSelector.extend({ + afterTabId: z.string().optional(), + activate: z.boolean().optional() +}) + const SaveMarkdownTab = ActivateTab.extend({ baseVersion: z .unknown() @@ -35,6 +40,21 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => runtime.activateMobileSessionTab(params.worktree, params.tabId) }), + defineMethod({ + name: 'session.tabs.close', + params: ActivateTab, + handler: async (params, { runtime }) => + runtime.closeMobileSessionTab(params.worktree, params.tabId) + }), + defineMethod({ + name: 'session.tabs.createTerminal', + params: CreateTerminalTab, + handler: async (params, { runtime }) => + runtime.createMobileSessionTerminal(params.worktree, { + afterTabId: params.afterTabId, + activate: params.activate + }) + }), defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 91148cf4fcb..fe606a52a24 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -242,8 +242,10 @@ function registerRuntimeWindowLifecycle( }) }, renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), - focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), + focusTerminal: (tabId, worktreeId, leafId) => + send('ui:focusTerminal', { tabId, worktreeId, leafId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), + closeSessionTab: (tabId, worktreeId) => send('ui:closeSessionTab', { tabId, worktreeId }), openFile: (worktreeId, filePath, relativePath) => send('ui:openFileFromMobile', { worktreeId, filePath, relativePath }), readMobileMarkdownTab: (worktreeId, tabId) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5c628289ee8..0584fca364a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1095,6 +1095,7 @@ export type PreloadApi = { callback: (data: { requestId: string worktreeId?: string + afterTabId?: string command?: string title?: string }) => void @@ -1116,10 +1117,15 @@ export type PreloadApi = { onRenameTerminal: ( callback: (data: { tabId: string; title: string | null }) => void ) => () => void - onFocusTerminal: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void + onFocusTerminal: ( + callback: (data: { tabId: string; worktreeId: string; leafId?: string | null }) => void + ) => () => void onFocusEditorTab: ( callback: (data: { tabId: string; worktreeId: string }) => void ) => () => void + onCloseSessionTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ) => () => void onOpenFileFromMobile: ( callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 25d4532be53..ac66d573137 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1863,13 +1863,20 @@ const api = { callback: (data: { requestId: string worktreeId?: string + afterTabId?: string command?: string title?: string }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { requestId: string; worktreeId?: string; command?: string; title?: string } + data: { + requestId: string + worktreeId?: string + afterTabId?: string + command?: string + title?: string + } ) => callback(data) ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) @@ -1913,11 +1920,11 @@ const api = { return () => ipcRenderer.removeListener('ui:renameTerminal', listener) }, onFocusTerminal: ( - callback: (data: { tabId: string; worktreeId: string }) => void + callback: (data: { tabId: string; worktreeId: string; leafId?: string | null }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } + data: { tabId: string; worktreeId: string; leafId?: string | null } ) => callback(data) ipcRenderer.on('ui:focusTerminal', listener) return () => ipcRenderer.removeListener('ui:focusTerminal', listener) @@ -1932,6 +1939,16 @@ const api = { ipcRenderer.on('ui:focusEditorTab', listener) return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) }, + onCloseSessionTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:closeSessionTab', listener) + return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) + }, onOpenFileFromMobile: ( callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void ): (() => void) => { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 059ccbbefa0..a12716a9057 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -168,6 +168,8 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -374,6 +376,8 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -579,6 +583,8 @@ describe('useIpcEvents updater integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -786,6 +792,8 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -987,6 +995,8 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -1183,6 +1193,8 @@ describe('useIpcEvents browser tab close routing', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -1397,6 +1409,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, @@ -1583,6 +1597,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, onFocusEditorTab: () => () => {}, + onCloseSessionTab: () => () => {}, + onOpenFileFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, onNewBrowserTab: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b342032924c..b8f2609eff1 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -25,6 +25,7 @@ import { } from '../../../shared/agent-status-types' import { isGitRepoKind } from '../../../shared/repo-kind' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' @@ -267,6 +268,31 @@ export function useIpcEvents(): void { // focus recency for Cmd+J. See docs/cmd-j-empty-query-ordering.md. store.markWorktreeVisited(worktreeId) const tab = store.createTab(worktreeId) + if (data.afterTabId) { + const createdUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id) + const anchorUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId) + if ( + createdUnifiedTab && + anchorUnifiedTab && + createdUnifiedTab.groupId === anchorUnifiedTab.groupId + ) { + const group = useAppStore + .getState() + .groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId) + const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id) + const anchorIndex = order.indexOf(anchorUnifiedTab.id) + order.splice( + anchorIndex === -1 ? order.length : anchorIndex + 1, + 0, + createdUnifiedTab.id + ) + useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order) + } + } store.setActiveTabType('terminal') store.setActiveTab(tab.id) store.revealWorktreeInSidebar(worktreeId) @@ -304,7 +330,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onFocusTerminal(({ tabId, worktreeId }) => { + window.api.ui.onFocusTerminal(({ tabId, worktreeId, leafId }) => { const store = useAppStore.getState() store.setActiveWorktree(worktreeId) // Why: CLI-driven focus is a user-initiated switch; stamp focus @@ -313,6 +339,9 @@ export function useIpcEvents(): void { store.setActiveView('terminal') store.setActiveTab(tabId) store.revealWorktreeInSidebar(worktreeId) + if (!focusRuntimeTerminalSurface(tabId, leafId)) { + focusTerminalTabSurface(tabId) + } }) ) @@ -336,6 +365,12 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onCloseSessionTab(({ tabId }) => { + useAppStore.getState().closeUnifiedTab(tabId) + }) + ) + unsubs.push( window.api.ui.onOpenFileFromMobile(({ worktreeId, filePath, relativePath }) => { const store = useAppStore.getState() diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 7c7731bd4a0..cea0748724a 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -64,4 +64,36 @@ describe('getRuntimeMobileSessionSyncKey', () => { expect(reordered).not.toBe(getRuntimeMobileSessionSyncKey(base)) }) + + it('changes when terminal split-pane layout changes', () => { + const base = makeState({ + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null + } + } + }) + + const split = getRuntimeMobileSessionSyncKey( + makeState({ + ...base, + terminalLayoutsByTabId: { + 'term-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' } + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + }) + ) + + expect(split).not.toBe(getRuntimeMobileSessionSyncKey(base)) + }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index f330f92fd43..0f183ff6bc3 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1,4 +1,9 @@ -import { paneLeafId, serializePaneTree } from '@/components/terminal-pane/layout-serialization' +/* eslint-disable max-lines -- Why: runtime graph sync and mobile session-tab publication share the same injected renderer state and terminal registry. Keeping them together prevents a second store/registry reader from drifting. */ +import { + collectLeafIdsInOrder, + paneLeafId, + serializePaneTree +} from '@/components/terminal-pane/layout-serialization' import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { AppState } from '@/store/types' @@ -31,6 +36,10 @@ let syncScheduled = false let syncEnabled = false let getStoreState: (() => AppState) | null = null let mobileSessionSnapshotVersion = 0 +const mobileSessionPublicationEpoch = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `renderer:${Date.now().toString(36)}` export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { getStoreState = getter @@ -47,6 +56,25 @@ export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => vo } } +export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | null): boolean { + const registered = registeredTabs.get(tabId) + const manager = registered?.getManager() + if (!manager) { + return false + } + if (!leafId) { + manager.getActivePane()?.terminal.focus() + return true + } + const pane = manager.getPanes().find((candidate) => paneLeafId(candidate.id) === leafId) + if (!pane) { + return false + } + manager.setActivePane(pane.id, { focus: true }) + scheduleRuntimeGraphSync() + return true +} + export function setRuntimeGraphSyncEnabled(enabled: boolean): void { syncEnabled = enabled if (enabled) { @@ -84,6 +112,8 @@ export function getRuntimeMobileSessionSyncKey(state: AppState): string { tabBarOrderByWorktree: state.tabBarOrderByWorktree, activeFileId: state.activeFileId, activeFileIdByWorktree: state.activeFileIdByWorktree, + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, openFiles: state.openFiles.map((file) => ({ id: file.id, filePath: file.filePath, @@ -196,17 +226,7 @@ function buildMobileSessionTabSnapshots(state: AppState): RuntimeMobileSessionTa if (!terminal) { continue } - tabs.push({ - type: 'terminal', - id: terminal.id, - title: terminal.customTitle ?? terminal.title ?? 'Terminal', - terminalTabId: terminal.id, - isActive: item.tabId - ? state.groupsByWorktree[worktreeId]?.some( - (group) => group.id === activeGroupId && group.activeTabId === item.tabId - ) === true - : state.activeTabId === terminal.id - }) + tabs.push(...buildMobileTerminalSurfaceTabs(state, terminal.id, worktreeId, item.tabId)) } else if (item.type === 'editor') { const file = state.openFiles.find( (candidate) => candidate.id === item.id && candidate.worktreeId === worktreeId @@ -223,6 +243,7 @@ function buildMobileSessionTabSnapshots(state: AppState): RuntimeMobileSessionTa const active = tabs.find((tab) => tab.isActive) ?? null snapshots.push({ worktree: worktreeId, + publicationEpoch: mobileSessionPublicationEpoch, snapshotVersion: ++mobileSessionSnapshotVersion, activeGroupId, activeTabId: active?.id ?? null, @@ -234,6 +255,69 @@ function buildMobileSessionTabSnapshots(state: AppState): RuntimeMobileSessionTa return snapshots } +function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { + return `${parentTabId}::${leafId}` +} + +function getRuntimeLeafIdsForTerminal(tabId: string, state: AppState): string[] { + const registered = registeredTabs.get(tabId) + const manager = registered?.getManager() + const liveLeafIds = manager?.getPanes().map((pane) => paneLeafId(pane.id)) ?? [] + if (liveLeafIds.length > 0) { + return liveLeafIds + } + + const layout = state.terminalLayoutsByTabId[tabId] + const persistedLeafIds = collectLeafIdsInOrder(layout?.root) + if (persistedLeafIds.length > 0) { + return persistedLeafIds + } + + // Why: a newly-created terminal tab can be in the store before TerminalPane + // mounts. Publish its deterministic first-pane surface so mobile does not + // fill the startup gap from terminal.list. + return [paneLeafId(1)] +} + +function buildMobileTerminalSurfaceTabs( + state: AppState, + terminalTabId: string, + worktreeId: string, + unifiedTabId?: string +): RuntimeMobileSessionSnapshotTab[] { + const terminal = (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === terminalTabId) + if (!terminal) { + return [] + } + + const isDesktopTabActive = unifiedTabId + ? state.groupsByWorktree[worktreeId]?.some( + (group) => + group.id === state.activeGroupIdByWorktree[worktreeId] && + group.activeTabId === unifiedTabId + ) === true + : state.activeTabId === terminal.id + const liveActiveLeafId = + registeredTabs.get(terminalTabId)?.getManager()?.getActivePane()?.id ?? null + const activeLeafId = + liveActiveLeafId !== null + ? paneLeafId(liveActiveLeafId) + : (state.terminalLayoutsByTabId[terminalTabId]?.activeLeafId ?? paneLeafId(1)) + const paneTitles = state.runtimePaneTitlesByTabId[terminalTabId] ?? {} + return getRuntimeLeafIdsForTerminal(terminalTabId, state).map((leafId) => { + const paneId = /^pane:(\d+)$/.exec(leafId)?.[1] + const paneTitle = paneId ? paneTitles[Number(paneId)] : undefined + return { + type: 'terminal' as const, + id: mobileTerminalSurfaceId(terminalTabId, leafId), + title: paneTitle ?? terminal.customTitle ?? terminal.title ?? 'Terminal', + parentTabId: terminalTabId, + leafId, + isActive: isDesktopTabActive && leafId === activeLeafId + } + }) +} + function buildMobileMarkdownTab( state: AppState, fileId: string, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 7895a75c129..2a3745126a4 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -74,7 +74,8 @@ export type RuntimeMobileSessionTerminalTab = { type: 'terminal' id: string title: string - terminalTabId: string + parentTabId: string + leafId: string isActive: boolean } @@ -110,12 +111,15 @@ export type RuntimeMobileSessionSnapshotTab = | RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab -export type RuntimeMobileSessionTerminalClientTab = Omit< - RuntimeMobileSessionTerminalTab, - 'terminalTabId' -> & { - terminal: string -} +export type RuntimeMobileSessionTerminalClientTab = + | (RuntimeMobileSessionTerminalTab & { + status: 'pending-handle' + terminal: null + }) + | (RuntimeMobileSessionTerminalTab & { + status: 'ready' + terminal: string + }) export type RuntimeMobileSessionClientTab = | RuntimeMobileSessionTerminalClientTab @@ -124,6 +128,7 @@ export type RuntimeMobileSessionClientTab = export type RuntimeMobileSessionTabsSnapshot = { worktree: string + publicationEpoch: string snapshotVersion: number activeGroupId: string | null activeTabId: string | null @@ -133,6 +138,7 @@ export type RuntimeMobileSessionTabsSnapshot = { export type RuntimeMobileSessionTabsResult = { worktree: string + publicationEpoch: string snapshotVersion: number activeGroupId: string | null activeTabId: string | null @@ -140,6 +146,20 @@ export type RuntimeMobileSessionTabsResult = { tabs: RuntimeMobileSessionClientTab[] } +export type RuntimeMobileSessionCreateTerminalResult = { + tab: RuntimeMobileSessionTerminalClientTab + publicationEpoch: string + snapshotVersion: number +} + +export type RuntimeMobileSessionTabsRemovedResult = RuntimeMobileSessionTabsResult & { + removed: true + activeGroupId: null + activeTabId: null + activeTabType: null + tabs: [] +} + export type RuntimeFileListEntry = { relativePath: string basename: string