feat(native-chat): add mobile default-view toggle for native chat (#9084)

* feat(native-chat): add mobile default-view toggle for native chat

Adds a per-device "Native chat" setting on mobile (Settings → Native chat)
that controls whether supported agent sessions open in the native chat view
or the raw terminal. Default stays terminal; flipping it on makes eligible
sessions render as native chat, mirroring the desktop default-view control.

- New orca:defaultSessionView preference (loadDefaultSessionView/save).
- Per-tab chat set refactored into a tri-state override map so a session can
  be pinned to terminal or chat regardless of the default; the legacy array
  format migrates to chat overrides on load.
- useMobileSessionViewMode resolves each tab as override ?? default and
  reloads the default on focus so a Settings change applies without remount.
- Toggle/action-sheet builders take an isTabChatView predicate.

* chore(skills): refresh skill bundle manifest for v1.4.144-rc.2

The v1.4.144-rc.2 release bump left resources/skills/current-manifest.json
pointing at rc.1, so verify:skill-bundle-manifest fails on any branch that
reaches it. Regenerated (appVersion only; no skill content changed) to
unblock CI. Unrelated to the native-chat toggle in this PR.

* fix(native-chat): harden mobile view preferences

* fix(native-chat): harden default view persistence

* fix(native-chat): serialize mobile view persistence

* fix(native-chat): reconcile failed mobile view saves

* fix(native-chat): fail closed on unreadable view overrides

* fix(native-chat): honor fail-closed view races
This commit is contained in:
Brennan Benson 2026-07-17 00:34:46 -07:00 committed by GitHub
parent c5d2275c35
commit 44686f324c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1293 additions and 89 deletions

View File

@ -184,6 +184,7 @@ export default function RootLayout() {
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />

View File

@ -1199,7 +1199,7 @@ export default function SessionScreen() {
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
})
const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const dictation = useMobileDictation({
client,
@ -5334,7 +5334,7 @@ export default function SessionScreen() {
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
chatTabIds,
isTabChatView: nativeChatController.isTabChatView,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,

View File

@ -0,0 +1,126 @@
import { View, Text, StyleSheet, Pressable, ScrollView, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { useMobileDefaultSessionViewPreference } from '../src/session/use-mobile-default-session-view-preference'
export default function NativeChatSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { defaultView, setDefaultView } = useMobileDefaultSessionViewPreference()
const chatDefault = defaultView === 'chat'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Back"
style={styles.backButton}
onPress={() => router.back()}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Native chat</Text>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on
this device. Terminal shows the raw CLI; native chat shows a chat interface like the
desktop app. You can still switch any individual session from its long-press menu.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in native chat</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in native chat"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
}
})

View File

@ -20,6 +20,7 @@ import {
LifeBuoy,
Mic,
Globe,
MessageSquare,
Terminal as TerminalIcon,
KeyRound
} from 'lucide-react-native'
@ -114,6 +115,15 @@ export default function SettingsScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Native chat</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/browser-settings')}

View File

@ -11,19 +11,19 @@ type ToggleTab = MobileNativeChatTab & {
export function getMobileNativeChatToggleActions(args: {
terminalHandle: string | null
tabs: readonly ToggleTab[]
chatTabIds: ReadonlySet<string>
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onClose: () => void
onToggle: (tabId: string) => void
}): ActionSheetAction[] {
const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args
const { terminalHandle, tabs, isTabChatView, onClose, onToggle } = args
const tab = terminalHandle
? tabs.find((candidate) => candidate.terminal === terminalHandle)
: null
if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) {
return []
}
const isChat = chatTabIds.has(tab.id)
const isChat = isTabChatView(tab.id)
return [
{
label: isChat ? 'Switch to terminal view' : 'Switch to chat view',

View File

@ -10,7 +10,7 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
target: Target | null
tabs: readonly TerminalTab[]
chatTabIds: ReadonlySet<string>
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
onToggleChat: (tabId: string) => void
@ -29,7 +29,7 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
tabs: args.tabs,
chatTabIds: args.chatTabIds,
isTabChatView: args.isTabChatView,
nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable,
onClose: args.onDismiss,
onToggle: args.onToggleChat

View File

@ -0,0 +1,167 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadDefaultSessionView,
saveDefaultSessionView,
type MobileSessionView
} from '../storage/session-view-preferences'
import {
useMobileDefaultSessionViewPreference,
type MobileDefaultSessionViewPreference
} from './use-mobile-default-session-view-preference'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
vi.mock('../storage/session-view-preferences', () => ({
DEFAULT_SESSION_VIEW: 'terminal',
loadDefaultSessionView: vi.fn(),
saveDefaultSessionView: vi.fn()
}))
describe('useMobileDefaultSessionViewPreference', () => {
let renderer: ReactTestRenderer | null = null
let preference: MobileDefaultSessionViewPreference | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
vi.mocked(saveDefaultSessionView).mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
preference = null
})
async function mount(): Promise<void> {
function Harness(): null {
preference = useMobileDefaultSessionViewPreference()
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
}
it('keeps a fast toggle authoritative over the initial read', async () => {
const initialLoad = deferred<MobileSessionView>()
vi.mocked(loadDefaultSessionView).mockReturnValue(initialLoad.promise)
await mount()
act(() => preference?.setDefaultView('chat'))
expect(preference?.defaultView).toBe('chat')
await act(async () => {
initialLoad.resolve('terminal')
await initialLoad.promise
})
expect(preference?.defaultView).toBe('chat')
expect(saveDefaultSessionView).toHaveBeenCalledWith('chat')
})
it('submits every change immediately so shared persistence preserves event order', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValue(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat')
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
})
it('reloads the persisted value when the latest write fails', async () => {
vi.mocked(loadDefaultSessionView)
.mockResolvedValueOnce('terminal')
.mockResolvedValueOnce('terminal')
vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable'))
await mount()
await act(async () => {
preference?.setDefaultView('chat')
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(preference?.defaultView).toBe('terminal')
expect(loadDefaultSessionView).toHaveBeenCalledTimes(2)
})
it('does not let an older failed write roll back a newer choice', async () => {
const recoveryLoad = deferred<MobileSessionView>()
vi.mocked(loadDefaultSessionView)
.mockResolvedValueOnce('terminal')
.mockReturnValueOnce(recoveryLoad.promise)
vi.mocked(saveDefaultSessionView)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
await act(async () => {
recoveryLoad.resolve('chat')
await recoveryLoad.promise
await Promise.resolve()
})
expect(preference?.defaultView).toBe('terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
})
it('submits the latest write before the Settings route unmounts', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValueOnce(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
act(() => renderer?.unmount())
renderer = null
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
})
})

View File

@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DEFAULT_SESSION_VIEW,
loadDefaultSessionView,
saveDefaultSessionView,
type MobileSessionView
} from '../storage/session-view-preferences'
export type MobileDefaultSessionViewPreference = {
defaultView: MobileSessionView
setDefaultView: (view: MobileSessionView) => void
}
/** Owns the optimistic Settings value while keeping AsyncStorage writes ordered. */
export function useMobileDefaultSessionViewPreference(): MobileDefaultSessionViewPreference {
const [defaultView, setDefaultViewState] = useState<MobileSessionView>(DEFAULT_SESSION_VIEW)
const mountedRef = useRef(false)
const mutationRevisionRef = useRef(0)
useEffect(() => {
mountedRef.current = true
const loadRevision = mutationRevisionRef.current
let stale = false
void loadDefaultSessionView().then((view) => {
// Why: a fast toggle is authoritative over the older storage read.
if (!stale && mutationRevisionRef.current === loadRevision) {
setDefaultViewState(view)
}
})
return () => {
stale = true
mountedRef.current = false
}
}, [])
const setDefaultView = useCallback((view: MobileSessionView) => {
const revision = mutationRevisionRef.current + 1
mutationRevisionRef.current = revision
setDefaultViewState(view)
// Why: persistence owns a shared queue, so invoking it at event time preserves
// mutation order even when this route unmounts and a new instance takes over.
void saveDefaultSessionView(view).catch(async () => {
const persisted = await loadDefaultSessionView()
if (mountedRef.current && mutationRevisionRef.current === revision) {
setDefaultViewState(persisted)
}
})
}, [])
return { defaultView, setDefaultView }
}

View File

@ -1,13 +1,11 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import {
parseAskFromStatus,
@ -31,7 +29,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
chatTabIds: Set<string>
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
@ -85,46 +84,10 @@ export function useMobileNativeChatController(args: {
nativeChatInputLeaseReady,
onSendError
} = args
const [chatTabIds, setChatTabIds] = useState<Set<string>>(new Set())
const chatTabIdsToggledRef = useRef(false)
useEffect(() => {
let active = true
// Re-arm per host/worktree so the fresh load can seed the new selection.
chatTabIdsToggledRef.current = false
void loadNativeChatTabIds(hostId, worktreeId).then((ids) => {
// Why: a toggle before this load resolves is authoritative; the load must
// not revert the user's in-memory choice back to the persisted state.
if (active && !chatTabIdsToggledRef.current) {
// Why: route reuse must clear the prior worktree's selection even when
// the newly loaded worktree has no saved chat tabs.
setChatTabIds(new Set(ids))
}
})
return () => {
active = false
}
}, [hostId, worktreeId])
const toggleTabChatView = useCallback(
(tabId: string) => {
chatTabIdsToggledRef.current = true
setChatTabIds((previous) => {
const next = new Set(previous)
if (next.has(tabId)) {
next.delete(tabId)
} else {
next.add(tabId)
}
void saveNativeChatTabIds(hostId, worktreeId, [...next])
return next
})
},
[hostId, worktreeId]
)
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const activeChatResolution =
activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId)
activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
@ -288,7 +251,7 @@ export function useMobileNativeChatController(args: {
)
return {
chatTabIds,
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,

View File

@ -0,0 +1,384 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadDefaultSessionView,
readSessionViewOverridesPreference,
updateSessionViewOverride,
type MobileSessionView
} from '../storage/session-view-preferences'
import {
useMobileSessionViewMode,
type MobileSessionViewModeController
} from './use-mobile-session-view-mode'
const focusEffectRuntime = vi.hoisted(() => ({
callback: null as null | (() => undefined | (() => void))
}))
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
vi.mock('expo-router', async () => {
const react = await import('react')
return {
// Run the focus callback once on mount, mirroring a focus.
useFocusEffect: (cb: () => undefined | (() => void)) => {
focusEffectRuntime.callback = cb
react.useEffect(() => cb(), [cb])
}
}
})
vi.mock('../storage/session-view-preferences', () => ({
loadDefaultSessionView: vi.fn(),
readSessionViewOverridesPreference: vi.fn(),
updateSessionViewOverride: vi.fn()
}))
describe('useMobileSessionViewMode', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileSessionViewModeController | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mocked(loadDefaultSessionView).mockReset().mockResolvedValue('terminal')
vi.mocked(readSessionViewOverridesPreference)
.mockReset()
.mockResolvedValue({ overrides: new Map(), loaded: true })
vi.mocked(updateSessionViewOverride).mockReset().mockResolvedValue(undefined)
focusEffectRuntime.callback = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
})
async function mount(args: {
defaultView: MobileSessionView
overrides?: Map<string, MobileSessionView>
}): Promise<void> {
vi.mocked(loadDefaultSessionView).mockResolvedValue(args.defaultView)
vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({
overrides: args.overrides ?? new Map(),
loaded: true
})
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
}
it('follows the default when a tab has no override', async () => {
await mount({ defaultView: 'terminal' })
expect(controller?.isTabChatView('t1')).toBe(false)
act(() => renderer?.unmount())
renderer = null
await mount({ defaultView: 'chat' })
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('lets a per-tab override win over the default', async () => {
await mount({
defaultView: 'chat',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(controller?.isTabChatView('t2')).toBe(true)
})
it('reloads the default on refocus after Settings changes it', async () => {
await mount({ defaultView: 'terminal' })
expect(controller?.isTabChatView('t1')).toBe(false)
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
await act(async () => {
focusEffectRuntime.callback?.()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('toggles from the effective view and persists the override', async () => {
await mount({ defaultView: 'chat' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal')
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('toggles a terminal-default tab into chat', async () => {
await mount({ defaultView: 'terminal' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'chat')
expect(controller?.isTabChatView('t1')).toBe(true)
})
it('does not expose overrides from the previous host while the next scope loads', async () => {
const nextScopeLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockImplementation((hostId) =>
hostId === 'h1'
? Promise.resolve({
overrides: new Map([['same-tab-id', 'chat' as const]]),
loaded: true
})
: nextScopeLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(props: { hostId: string; worktreeId: string }): null {
controller = useMobileSessionViewMode(props)
return null
}
await act(async () => {
renderer = create(createElement(Harness, { hostId: 'h1', worktreeId: 'w1' }))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('same-tab-id')).toBe(true)
await act(async () => {
renderer?.update(createElement(Harness, { hostId: 'h2', worktreeId: 'w2' }))
await Promise.resolve()
})
expect(controller?.isTabChatView('same-tab-id')).toBe(false)
await act(async () => {
nextScopeLoad.resolve(new Map())
await Promise.resolve()
})
})
it('merges a toggle made during load with the other persisted overrides', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('new-tab'))
expect(controller?.isTabChatView('new-tab')).toBe(true)
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
await act(async () => {
overridesLoad.resolve(new Map([['saved-tab', 'chat']]))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('saved-tab')).toBe(true)
expect(controller?.isTabChatView('new-tab')).toBe(true)
})
it('toggles from the fail-closed view while overrides load under a chat default', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('new-tab')).toBe(false)
act(() => controller?.toggleTabChatView('new-tab'))
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
expect(controller?.isTabChatView('new-tab')).toBe(true)
await act(async () => {
overridesLoad.resolve(new Map())
await overridesLoad.promise
await Promise.resolve()
})
})
it('submits rapid mutations in event order', async () => {
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => controller?.toggleTabChatView('t2'))
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat')
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat')
})
it('reconciles the latest optimistic override when persistence fails', async () => {
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
await mount({
defaultView: 'terminal',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('does not let an older failed override roll back a newer choice', async () => {
const recoveryLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(updateSessionViewOverride)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined)
await mount({ defaultView: 'terminal' })
vi.mocked(readSessionViewOverridesPreference).mockReturnValueOnce(
recoveryLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('t1'))
await act(async () => {
recoveryLoad.resolve(new Map([['t1', 'chat']]))
await recoveryLoad.promise
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal')
})
it('fails closed when both an override write and its recovery read fail', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({ overrides: new Map(), loaded: false })
await mount({
defaultView: 'chat',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('finishes an early toggle save after the route unmounts', async () => {
const overridesLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(readSessionViewOverridesPreference).mockReturnValue(
overridesLoad.promise.then((overrides) => ({ overrides, loaded: true }))
)
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => controller?.toggleTabChatView('new-tab'))
act(() => renderer?.unmount())
renderer = null
await act(async () => {
overridesLoad.resolve(new Map([['saved-tab', 'terminal']]))
await Promise.resolve()
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenCalledWith('h', 'w', 'new-tab', 'chat')
})
it('does not reload a failed override after the route unmounts', async () => {
const failedWrite = deferred<void>()
vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise)
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => renderer?.unmount())
renderer = null
await act(async () => {
failedWrite.reject(new Error('storage unavailable'))
await Promise.resolve()
await Promise.resolve()
})
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('fails closed to terminal when overrides cannot be read under a chat default', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({
overrides: new Map(),
loaded: false
})
function Harness(): null {
controller = useMobileSessionViewMode({ hostId: 'h', worktreeId: 'w' })
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
})
})

View File

@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import {
loadDefaultSessionView,
readSessionViewOverridesPreference,
updateSessionViewOverride,
type MobileSessionView,
type SessionViewOverridesPreference
} from '../storage/session-view-preferences'
type ViewOverridesState = {
hostId: string
worktreeId: string
overrides: Map<string, MobileSessionView>
loaded: boolean
}
type ViewOverridesRuntime = {
hostId: string
worktreeId: string
loadPromise: Promise<SessionViewOverridesPreference>
currentOverrides: Map<string, MobileSessionView>
mutationRevisions: Map<string, number>
}
function isOverrideScope(state: ViewOverridesState, hostId: string, worktreeId: string): boolean {
return state.hostId === hostId && state.worktreeId === worktreeId
}
function mergeOverrides(
persisted: ReadonlyMap<string, MobileSessionView>,
current: ReadonlyMap<string, MobileSessionView>
): Map<string, MobileSessionView> {
const merged = new Map(persisted)
for (const [tabId, view] of current) {
merged.set(tabId, view)
}
return merged
}
export type MobileSessionViewModeController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
}
/** Resolves each tab's terminal/chat view: a per-device default (reloaded on focus
* so a Settings change applies without remounting the route) overlaid by persisted
* per-tab overrides that pin a session regardless of what the default later becomes. */
export function useMobileSessionViewMode(args: {
hostId: string
worktreeId: string
}): MobileSessionViewModeController {
const { hostId, worktreeId } = args
const [viewOverridesState, setViewOverridesState] = useState<ViewOverridesState>(() => ({
hostId,
worktreeId,
overrides: new Map(),
loaded: false
}))
const viewOverridesStateRef = useRef(viewOverridesState)
viewOverridesStateRef.current = viewOverridesState
const viewOverridesRuntimeRef = useRef<ViewOverridesRuntime | null>(null)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const ensureViewOverridesRuntime = useCallback((scopeHostId: string, scopeWorktreeId: string) => {
const current = viewOverridesRuntimeRef.current
if (current?.hostId === scopeHostId && current.worktreeId === scopeWorktreeId) {
return current
}
const next: ViewOverridesRuntime = {
hostId: scopeHostId,
worktreeId: scopeWorktreeId,
loadPromise: readSessionViewOverridesPreference(scopeHostId, scopeWorktreeId),
currentOverrides: new Map(),
mutationRevisions: new Map()
}
viewOverridesRuntimeRef.current = next
return next
}, [])
const [defaultView, setDefaultView] = useState<MobileSessionView>('terminal')
// Why: the toggle callback reads the live default without depending on it, so
// its identity stays stable and it never captures a stale default.
const defaultViewRef = useRef(defaultView)
defaultViewRef.current = defaultView
useEffect(() => {
let active = true
const runtime = ensureViewOverridesRuntime(hostId, worktreeId)
void runtime.loadPromise.then((preference) => {
if (!active) {
return
}
// Why: toggles made during the read are authoritative, but must not
// discard unrelated persisted overrides from the same worktree.
const merged = mergeOverrides(preference.overrides, runtime.currentOverrides)
runtime.currentOverrides = merged
// Why: an unreadable override store cannot safely be treated as empty when
// the default is chat; fail closed to terminal until a user toggles.
const next = { hostId, worktreeId, overrides: merged, loaded: preference.loaded }
viewOverridesStateRef.current = next
setViewOverridesState(next)
})
return () => {
active = false
}
}, [ensureViewOverridesRuntime, hostId, worktreeId])
// Why: reload on focus so returning from Settings picks up a changed default.
useFocusEffect(
useCallback(() => {
let active = true
void loadDefaultSessionView().then((view) => {
if (active) {
setDefaultView(view)
}
})
return () => {
active = false
}
}, [])
)
const isTabChatView = useCallback(
(tabId: string): boolean => {
if (!isOverrideScope(viewOverridesState, hostId, worktreeId)) {
return false
}
const override = viewOverridesState.overrides.get(tabId)
// Until this scope loads, only an immediate user toggle is authoritative;
// defaulting other tabs to terminal avoids activating stale cross-host chat.
return (override ?? (viewOverridesState.loaded ? defaultView : 'terminal')) === 'chat'
},
[defaultView, hostId, viewOverridesState, worktreeId]
)
const toggleTabChatView = useCallback(
(tabId: string) => {
const current = viewOverridesStateRef.current
const currentScope = isOverrideScope(current, hostId, worktreeId)
? current
: {
hostId,
worktreeId,
overrides: new Map<string, MobileSessionView>(),
loaded: false
}
const overrides = new Map(currentScope.overrides)
// Flip from the tab's effective view (its override, else the default), so
// a tab following a chat default can still be pinned back to terminal.
const fallbackView = currentScope.loaded ? defaultViewRef.current : 'terminal'
const currentlyChat = (overrides.get(tabId) ?? fallbackView) === 'chat'
const nextView = currentlyChat ? 'terminal' : 'chat'
overrides.set(tabId, nextView)
const next = { ...currentScope, overrides }
viewOverridesStateRef.current = next
setViewOverridesState(next)
const runtime = ensureViewOverridesRuntime(hostId, worktreeId)
runtime.currentOverrides = overrides
const revision = (runtime.mutationRevisions.get(tabId) ?? 0) + 1
runtime.mutationRevisions.set(tabId, revision)
// Why: enqueue the individual mutation immediately so a remounted route
// cannot reorder it or replace unrelated overrides with a stale snapshot.
void updateSessionViewOverride(hostId, worktreeId, tabId, nextView).catch(async () => {
if (!mountedRef.current || viewOverridesRuntimeRef.current !== runtime) {
return
}
const preference = await readSessionViewOverridesPreference(hostId, worktreeId)
// Why: a failed older write must not roll back a newer choice for this tab.
if (
!mountedRef.current ||
viewOverridesRuntimeRef.current !== runtime ||
runtime.mutationRevisions.get(tabId) !== revision
) {
return
}
// Why: if recovery is also unreadable, fail closed instead of treating an
// unknown store as empty or restoring an earlier optimistic mutation.
const reconciled = preference.loaded
? mergeOverrides(preference.overrides, runtime.currentOverrides)
: new Map(runtime.currentOverrides)
const recoveredOverride = preference.loaded ? preference.overrides.get(tabId) : 'terminal'
if (recoveredOverride) {
reconciled.set(tabId, recoveredOverride)
} else {
reconciled.delete(tabId)
}
runtime.currentOverrides = reconciled
const latest = viewOverridesStateRef.current
if (!isOverrideScope(latest, hostId, worktreeId)) {
return
}
const reconciledState = { ...latest, overrides: reconciled, loaded: preference.loaded }
viewOverridesStateRef.current = reconciledState
setViewOverridesState(reconciledState)
})
},
[ensureViewOverridesRuntime, hostId, worktreeId]
)
return { isTabChatView, toggleTabChatView }
}

View File

@ -8,7 +8,6 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadNativeChatTabIds,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
@ -18,11 +17,17 @@ import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveNativeChatTabIds,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
import {
loadDefaultSessionView,
loadSessionViewOverrides,
readSessionViewOverridesPreference,
saveDefaultSessionView,
updateSessionViewOverride
} from './session-view-preferences'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
@ -31,27 +36,204 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('native chat tab preference', () => {
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('session view preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('loads and saves tab ids under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
it('defaults to terminal and persists the chat default', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:defaultSessionView')
await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([
'tab-1',
'tab-2'
await saveDefaultSessionView('chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:defaultSessionView', 'chat')
vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus')
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
})
it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' })
)
const loaded = await loadSessionViewOverrides('host/one', 'folder:C:\\repo')
expect([...loaded.entries()]).toEqual([
['tab-1', 'chat'],
['tab-2', 'terminal']
])
expect(AsyncStorage.getItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo'
)
await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2'])
await updateSessionViewOverride('host/one', 'folder:C:\\repo', 'tab-2', 'chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['tab-2'])
JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'chat' })
)
})
it('migrates the legacy array format to chat overrides', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
const loaded = await loadSessionViewOverrides('host', 'wt')
expect([...loaded.entries()]).toEqual([
['tab-1', 'chat'],
['tab-2', 'chat']
])
})
it('serializes default writes across callers and makes reads wait for the latest', async () => {
let stored = 'terminal'
const firstWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (_key, value) => {
await firstWrite.promise
stored = value
})
.mockImplementation(async (_key, value) => {
stored = value
})
const older = saveDefaultSessionView('chat')
await Promise.resolve()
const newer = saveDefaultSessionView('terminal')
const reloaded = loadDefaultSessionView()
await Promise.resolve()
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
firstWrite.resolve()
await Promise.all([older, newer])
await expect(reloaded).resolves.toBe('terminal')
expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(1, 'orca:defaultSessionView', 'chat')
expect(AsyncStorage.setItem).toHaveBeenNthCalledWith(2, 'orca:defaultSessionView', 'terminal')
})
it('continues the shared default queue after a failed write', async () => {
let stored = 'terminal'
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockImplementation(async (_key, value) => {
stored = value
})
const failed = expect(saveDefaultSessionView('terminal')).rejects.toThrow('storage unavailable')
const latest = saveDefaultSessionView('chat')
await failed
await latest
await expect(loadDefaultSessionView()).resolves.toBe('chat')
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(2)
})
it('orders per-tab mutations across callers without losing saved siblings', async () => {
let stored = JSON.stringify({ saved: 'chat' })
const firstWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (_key, value) => {
await firstWrite.promise
stored = value
})
.mockImplementation(async (_key, value) => {
stored = value
})
const first = updateSessionViewOverride('host', 'worktree', 'first', 'terminal')
await vi.waitFor(() => expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1))
const second = updateSessionViewOverride('host', 'worktree', 'second', 'chat')
const reloaded = loadSessionViewOverrides('host', 'worktree')
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
firstWrite.resolve()
await Promise.all([first, second])
await expect(reloaded).resolves.toEqual(
new Map([
['saved', 'chat'],
['first', 'terminal'],
['second', 'chat']
])
)
})
it('does not globally block updates for a different host and worktree', async () => {
const blockedWrite = deferred<void>()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key) => {
if (key.includes('blocked-host')) {
await blockedWrite.promise
}
})
const blocked = updateSessionViewOverride('blocked-host', 'worktree', 'tab', 'chat')
await Promise.resolve()
await Promise.resolve()
await updateSessionViewOverride('other-host', 'worktree', 'tab', 'terminal')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:other-host:worktree',
JSON.stringify({ tab: 'terminal' })
)
blockedWrite.resolve()
await blocked
})
it('continues a scoped override queue after a failed write', async () => {
let stored: string | null = null
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => stored)
vi.mocked(AsyncStorage.setItem)
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockImplementation(async (_key, value) => {
stored = value
})
const failed = updateSessionViewOverride('host', 'worktree', 'first', 'chat')
const latest = updateSessionViewOverride('host', 'worktree', 'second', 'terminal')
await expect(failed).rejects.toThrow('storage unavailable')
await latest
await expect(loadSessionViewOverrides('host', 'worktree')).resolves.toEqual(
new Map([['second', 'terminal']])
)
})
it('does not replace saved overrides after a transient read failure', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
overrides: new Map(),
loaded: false
})
await expect(updateSessionViewOverride('host', 'worktree', 'tab', 'chat')).rejects.toThrow(
'Session view overrides could not be read'
)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('repairs invalid preference data on the next user mutation', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
await updateSessionViewOverride('host', 'worktree', 'tab', 'chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host:worktree',
JSON.stringify({ tab: 'chat' })
)
})
})

View File

@ -2,35 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
function nativeChatTabIdsKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
/** Tab ids currently showing native chat, scoped to the paired host and worktree
* so colliding remote ids cannot activate a transcript watcher on another host. */
export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId))
const parsed = raw ? (JSON.parse(raw) as unknown) : null
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
export async function saveNativeChatTabIds(
hostId: string,
worktreeId: string,
ids: string[]
): Promise<void> {
try {
await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids))
} catch {
// Best-effort persistence; a failed write just forgets the view choice.
}
}
export type PushNotificationsPreference = {
readonly value: boolean | null

View File

@ -0,0 +1,141 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
/** How a supported agent session opens: the raw terminal or the native chat view. */
export type MobileSessionView = 'terminal' | 'chat'
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
// Why: default stays terminal so native chat remains strictly opt-in.
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
let defaultViewWriteBarrier: Promise<void> | null = null
const overrideUpdateBarriers = new Map<string, Promise<void>>()
function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
if (defaultViewWriteBarrier === barrier) {
defaultViewWriteBarrier = null
}
}
/** Global (per-device) default for how supported agent sessions open. */
export async function loadDefaultSessionView(): Promise<MobileSessionView> {
await defaultViewWriteBarrier
try {
const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY)
return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW
} catch {
return DEFAULT_SESSION_VIEW
}
}
export function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.
const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() =>
AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view)
)
const barrier = write.catch(() => undefined)
defaultViewWriteBarrier = barrier
void barrier.then(() => clearDefaultViewWriteBarrier(barrier))
return write
}
export type SessionViewOverridesPreference = {
overrides: Map<string, MobileSessionView>
loaded: boolean
}
async function readSessionViewOverridesStorage(
key: string
): Promise<SessionViewOverridesPreference> {
let raw: string | null
try {
raw = await AsyncStorage.getItem(key)
} catch {
return { overrides: new Map(), loaded: false }
}
if (!raw) {
return { overrides: new Map(), loaded: true }
}
let parsed: unknown
try {
parsed = JSON.parse(raw) as unknown
} catch {
// Invalid preference data is safe to replace on the next user mutation.
return { overrides: new Map(), loaded: true }
}
// Legacy format: an array of tab ids that were showing native chat.
if (Array.isArray(parsed)) {
return {
overrides: new Map(
parsed
.filter((id): id is string => typeof id === 'string')
.map((id) => [id, 'chat' as const])
),
loaded: true
}
}
if (parsed && typeof parsed === 'object') {
const entries = Object.entries(parsed as Record<string, unknown>).filter(
(entry): entry is [string, MobileSessionView] =>
entry[1] === 'terminal' || entry[1] === 'chat'
)
return { overrides: new Map(entries), loaded: true }
}
return { overrides: new Map(), loaded: true }
}
/** Per-tab session-view overrides that win over the global default, scoped to the
* paired host and worktree so colliding remote ids cannot activate a transcript
* watcher on another host. */
export async function loadSessionViewOverrides(
hostId: string,
worktreeId: string
): Promise<Map<string, MobileSessionView>> {
return (await readSessionViewOverridesPreference(hostId, worktreeId)).overrides
}
/** Reads overrides without conflating an empty preference with unavailable storage. */
export async function readSessionViewOverridesPreference(
hostId: string,
worktreeId: string
): Promise<SessionViewOverridesPreference> {
const key = sessionViewOverridesKey(hostId, worktreeId)
await overrideUpdateBarriers.get(key)
return readSessionViewOverridesStorage(key)
}
/** Persists one user mutation without replacing sibling overrides from another mount. */
export async function updateSessionViewOverride(
hostId: string,
worktreeId: string,
tabId: string,
view: MobileSessionView
): Promise<void> {
const key = sessionViewOverridesKey(hostId, worktreeId)
const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve()
const update = previous.then(async () => {
const current = await readSessionViewOverridesStorage(key)
// Why: a transient read failure must not replace valid saved siblings with
// a partial map containing only the latest tab.
if (!current.loaded) {
throw new Error('Session view overrides could not be read')
}
current.overrides.set(tabId, view)
await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
})
const barrier = update.catch(() => undefined)
overrideUpdateBarriers.set(key, barrier)
try {
await update
} finally {
if (overrideUpdateBarriers.get(key) === barrier) {
overrideUpdateBarriers.delete(key)
}
}
}