Fix mobile session tab authority

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 00:56:56 -04:00
co-authored by Orca
parent 0f93626ccc
commit 57a70d2ac0
15 changed files with 724 additions and 168 deletions
+13 -1
View File
@@ -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<string | null>(null)
const [openingPath, setOpeningPath] = useState<string | null>(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
</Text>
<Text style={styles.meta} numberOfLines={1}>
{name || 'Active worktree'}
{worktreeLabel}
{truncated ? ' - Showing first 5000' : ''}
</Text>
</View>
+147 -118
View File
@@ -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<MobileSessionTab, { type: 'terminal' }> => 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<MobileSessionTab, { type: 'terminal' }>
}
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<MobileSessionTab, { type: 'terminal' }> => 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) => (
<Pressable
key={t.id}
style={[
styles.tab,
(t.type === 'terminal'
? t.terminal === activeHandle
: t.id === activeSessionTabId) && styles.tabActive
]}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
onPress={() => 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() {
<Text
style={[
styles.tabText,
(t.type === 'terminal'
? t.terminal === activeHandle
: t.id === activeSessionTabId) && styles.tabTextActive
t.id === activeSessionTabId && styles.tabTextActive
]}
numberOfLines={1}
>
@@ -2282,6 +2282,13 @@ export default function SessionScreen() {
</Animated.View>
)}
</View>
) : activePendingTerminalTab ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.emptyText}>
{activePendingTerminalTab.title || 'Loading terminal'}
</Text>
</View>
) : (
<View
style={styles.terminalFrame}
@@ -2550,6 +2557,17 @@ export default function SessionScreen() {
showToast('Path copied')
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
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)}
+17 -7
View File
@@ -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<CompatVerdict, { kind: 'blocked' }>
@@ -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) {
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{/* 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 ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(RELEASES_URL)
void Linking.openURL(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>Open GitHub Releases</Text>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
) : null}
<Pressable
+118
View File
@@ -620,6 +620,124 @@ describe('OrcaRuntimeService', () => {
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:')
+171 -17
View File
@@ -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<RuntimeMarkdownReadTabResult>
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<string, RuntimeLeafRecord>()
// 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<RuntimeMobileSessionCreateTerminalResult> {
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<RuntimeMobileSessionCreateTerminalResult> {
const existing = this.findMobileTerminalSurface(worktreeId, parentTabId)
if (existing) {
return Promise.resolve(existing)
}
return new Promise<RuntimeMobileSessionCreateTerminalResult>((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<string> {
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<string>()
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,
@@ -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,
@@ -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,
@@ -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) =>
+7 -1
View File
@@ -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
+20 -3
View File
@@ -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) => {
@@ -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: () => () => {},
+36 -1
View File
@@ -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()
@@ -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))
})
})
+96 -12
View File
@@ -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,
+27 -7
View File
@@ -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