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.
This commit is contained in:
Brennan Benson
2026-07-16 16:26:29 -07:00
parent dd0f4c39c8
commit b564bc91e9
10 changed files with 461 additions and 75 deletions
@@ -1200,7 +1200,8 @@ export default function SessionScreen() {
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
})
const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const { isTabChatView, toggleTabChatView, showNativeChat, showNativeChatRef } =
nativeChatController
const dictation = useMobileDictation({
client,
@@ -5416,7 +5417,7 @@ export default function SessionScreen() {
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
chatTabIds,
isTabChatView,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
+139
View File
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react'
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 { loadDefaultSessionView, saveDefaultSessionView } from '../src/storage/preferences'
export default function NativeChatSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [chatDefault, setChatDefault] = useState(false)
// Why: a fast toggle before the initial load resolves must win — otherwise the
// delayed read would clobber the user's choice with the stored (stale) value.
const userToggledRef = useRef(false)
useEffect(() => {
let stale = false
void loadDefaultSessionView().then((view) => {
if (!stale && !userToggledRef.current) {
setChatDefault(view === 'chat')
}
})
return () => {
stale = true
}
}, [])
const toggleDefault = useCallback((next: boolean) => {
userToggledRef.current = true
setChatDefault(next)
void saveDefaultSessionView(next ? 'chat' : 'terminal')
}, [])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable 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
value={chatDefault}
onValueChange={toggleDefault}
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
}
})
+10
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')}
@@ -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
@@ -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,108 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
loadDefaultSessionView,
loadSessionViewOverrides,
saveSessionViewOverrides,
type MobileSessionView
} from '../storage/preferences'
import {
useMobileSessionViewMode,
type MobileSessionViewModeController
} from './use-mobile-session-view-mode'
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)) => react.useEffect(() => cb(), [cb])
}
})
vi.mock('../storage/preferences', () => ({
loadDefaultSessionView: vi.fn(),
loadSessionViewOverrides: vi.fn(),
saveSessionViewOverrides: vi.fn()
}))
describe('useMobileSessionViewMode', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileSessionViewModeController | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mocked(loadDefaultSessionView).mockResolvedValue('terminal')
vi.mocked(loadSessionViewOverrides).mockResolvedValue(new Map())
vi.mocked(saveSessionViewOverrides).mockReset().mockResolvedValue(undefined)
})
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(loadSessionViewOverrides).mockResolvedValue(args.overrides ?? new Map())
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('toggles from the effective view and persists the override', async () => {
await mount({ defaultView: 'chat' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
const call = vi.mocked(saveSessionViewOverrides).mock.calls.at(-1)
expect(call?.[0]).toBe('h')
expect(call?.[1]).toBe('w')
expect(call?.[2].get('t1')).toBe('terminal')
expect(controller?.isTabChatView('t1')).toBe(false)
})
it('toggles a terminal-default tab into chat', async () => {
await mount({ defaultView: 'terminal' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(vi.mocked(saveSessionViewOverrides).mock.calls.at(-1)?.[2].get('t1')).toBe('chat')
expect(controller?.isTabChatView('t1')).toBe(true)
})
})
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import {
loadDefaultSessionView,
loadSessionViewOverrides,
saveSessionViewOverrides,
type MobileSessionView
} from '../storage/preferences'
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 [viewOverrides, setViewOverrides] = useState<Map<string, MobileSessionView>>(new Map())
const viewOverridesToggledRef = useRef(false)
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
// Re-arm per host/worktree so the fresh load can seed the new overrides.
viewOverridesToggledRef.current = false
void loadSessionViewOverrides(hostId, worktreeId).then((overrides) => {
// 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 && !viewOverridesToggledRef.current) {
setViewOverrides(new Map(overrides))
}
})
return () => {
active = false
}
}, [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 => (viewOverrides.get(tabId) ?? defaultView) === 'chat',
[viewOverrides, defaultView]
)
const toggleTabChatView = useCallback(
(tabId: string) => {
viewOverridesToggledRef.current = true
setViewOverrides((previous) => {
const next = new Map(previous)
// 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 currentlyChat = (previous.get(tabId) ?? defaultViewRef.current) === 'chat'
next.set(tabId, currentlyChat ? 'terminal' : 'chat')
void saveSessionViewOverrides(hostId, worktreeId, next)
return next
})
},
[hostId, worktreeId]
)
return { isTabChatView, toggleTabChatView }
}
+41 -10
View File
@@ -8,7 +8,8 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadNativeChatTabIds,
loadDefaultSessionView,
loadSessionViewOverrides,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
@@ -16,9 +17,10 @@ import {
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
saveDefaultSessionView,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveNativeChatTabIds,
saveSessionViewOverrides,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
@@ -31,29 +33,58 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('native chat tab preference', () => {
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 saves 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 saveSessionViewOverrides(
'host/one',
'folder:C:\\repo',
new Map([['tab-2', 'chat' as const]])
)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['tab-2'])
JSON.stringify({ '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']
])
})
})
describe('push notification preference', () => {
+64 -15
View File
@@ -2,33 +2,82 @@ 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:'
/** How a supported agent session opens: the raw terminal or the native chat view. */
export type MobileSessionView = 'terminal' | 'chat'
function nativeChatTabIdsKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
/** 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[]> {
// Why: default stays terminal so the toggle is strictly opt-in — flipping it to
// chat is what makes new/unpinned supported sessions open in the native chat view.
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
/** Global (per-device) default for how supported agent sessions open. */
export async function loadDefaultSessionView(): Promise<MobileSessionView> {
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') : []
const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY)
return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW
} catch {
return []
return DEFAULT_SESSION_VIEW
}
}
export async function saveNativeChatTabIds(
export async function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
await AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view)
}
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
/** 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. Absent tabs follow the default; an explicit entry
* pins a tab to terminal or chat regardless of what the default later becomes. */
export async function loadSessionViewOverrides(
hostId: string,
worktreeId: string
): Promise<Map<string, MobileSessionView>> {
try {
const raw = await AsyncStorage.getItem(sessionViewOverridesKey(hostId, worktreeId))
if (!raw) {
return new Map()
}
const parsed = JSON.parse(raw) as unknown
// Legacy format: an array of tab ids that were showing native chat.
if (Array.isArray(parsed)) {
return new Map(
parsed
.filter((id): id is string => typeof id === 'string')
.map((id) => [id, 'chat' as const])
)
}
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 new Map(entries)
}
return new Map()
} catch {
return new Map()
}
}
export async function saveSessionViewOverrides(
hostId: string,
worktreeId: string,
ids: string[]
overrides: ReadonlyMap<string, MobileSessionView>
): Promise<void> {
try {
await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids))
await AsyncStorage.setItem(
sessionViewOverridesKey(hostId, worktreeId),
JSON.stringify(Object.fromEntries(overrides))
)
} catch {
// Best-effort persistence; a failed write just forgets the view choice.
// Best-effort persistence; a failed write just forgets the per-session view choice.
}
}