From 1a92e71ef2db7f03c6cf9d9efc0ccf63c05120ee Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 11 May 2026 22:57:25 -0400 Subject: [PATCH] Keep fallback terminals visible on mobile Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 81 ++++++++++++++++--- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 34a1fa333da..b32bd5873dd 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -127,6 +127,33 @@ type DirtyMarkdownDraft = { content: string } +function mergeTerminalFallbackTabs( + tabs: MobileSessionTab[], + terminals: Terminal[], + activeHandle: string | null +): MobileSessionTab[] { + if (terminals.length === 0) { + return tabs + } + const handles = new Set( + tabs + .filter( + (tab): tab is Extract => tab.type === 'terminal' + ) + .map((tab) => tab.terminal) + ) + const fallbackTabs = terminals + .filter((terminal) => !handles.has(terminal.handle)) + .map((terminal) => ({ + type: 'terminal' as const, + id: terminal.handle, + title: terminal.title, + terminal: terminal.handle, + isActive: terminal.handle === activeHandle + })) + return fallbackTabs.length > 0 ? [...tabs, ...fallbackTabs] : tabs +} + type TerminalCreateResult = { terminal: { handle: string @@ -386,6 +413,7 @@ export default function SessionScreen() { // docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) const [terminals, setTerminals] = useState([]) + const terminalsRef = useRef([]) const [sessionTabs, setSessionTabs] = useState([]) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') @@ -469,7 +497,10 @@ export default function SessionScreen() { const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null const canSend = - connState === 'connected' && activeHandle != null && activeSessionTab?.type === 'terminal' + connState === 'connected' && + activeHandle != null && + activeSessionTab?.type !== 'markdown' && + activeSessionTab?.type !== 'file' const showToast = useCallback((message: string, durationMs = 1200) => { setToastMessage(message) @@ -845,6 +876,7 @@ export default function SessionScreen() { }) setTerminals(deduped) + terminalsRef.current = deduped setTerminalsLoaded(true) if (activeSessionTabTypeRef.current !== 'terminal') { @@ -904,8 +936,15 @@ export default function SessionScreen() { title: tab.title || 'Terminal', isActive: tab.isActive })) - setTerminals(terminalTabs) - lastKnownTerminalCountRef.current = terminalTabs.length + const handles = new Set(terminalTabs.map((tab) => tab.handle)) + const fallbacks = terminalsRef.current.filter((terminal) => !handles.has(terminal.handle)) + const mergedTerminalsForActive = [...terminalTabs, ...fallbacks] + setTerminals(mergedTerminalsForActive) + terminalsRef.current = mergedTerminalsForActive + lastKnownTerminalCountRef.current = Math.max( + lastKnownTerminalCountRef.current, + terminalTabs.length + ) if (nextTabs.length > 0) { setTerminalsLoaded(true) } @@ -927,6 +966,21 @@ export default function SessionScreen() { } } } + 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(currentHandle) + setActiveHandle(currentHandle) + subscribeToTerminal(currentHandle) + return + } activeSessionTabTypeRef.current = active?.type ?? null setActiveSessionTabId(active?.id ?? null) if (active?.type === 'terminal') { @@ -1350,6 +1404,7 @@ export default function SessionScreen() { pendingActiveSessionTabIdRef.current = null setActiveHandle(null) setTerminals([]) + terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) setMarkdownDocs(new Map()) @@ -1860,11 +1915,16 @@ export default function SessionScreen() { // 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)) return prev - return [ + if (prev.some((t) => t.handle === created.handle)) { + terminalsRef.current = prev + return prev + } + const next = [ ...prev, { handle: created.handle, title: created.title || 'Terminal', isActive: true } ] + terminalsRef.current = next + return next }) subscribeToTerminal(created.handle) setTimeout(() => void fetchTerminals(), 500) @@ -1890,13 +1950,15 @@ export default function SessionScreen() { title }) if (response.ok) { - setTerminals((prev) => - prev.map((terminal) => + setTerminals((prev) => { + const next = prev.map((terminal) => terminal.handle === target.handle ? { ...terminal, title: title || 'Terminal' } : terminal ) - ) + terminalsRef.current = next + return next + }) setTimeout(() => void fetchTerminals(), 300) } } catch { @@ -1917,6 +1979,7 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(target.handle) const next = terminals.filter((terminal) => terminal.handle !== target.handle) setTerminals(next) + terminalsRef.current = next if (activeHandleRef.current === target.handle) { const replacement = next[0] ?? null activeHandleRef.current = replacement?.handle ?? null @@ -1940,7 +2003,7 @@ export default function SessionScreen() { const visibleTabs: MobileSessionTab[] = sessionTabs.length > 0 - ? sessionTabs + ? mergeTerminalFallbackTabs(sessionTabs, terminals, activeHandle) : terminals.map((terminal) => ({ type: 'terminal' as const, id: terminal.handle,