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:
parent
c5d2275c35
commit
44686f324c
|
|
@ -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 }} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue