mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:')
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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: () => () => {},
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user