From b564bc91e9f46cb440ef00f27757c9415b7ae2ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:26:29 -0700 Subject: [PATCH] feat(native-chat): add mobile default-view toggle for native chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/h/[hostId]/session/[worktreeId].tsx | 5 +- mobile/app/native-chat-settings.tsx | 139 ++++++++++++++++++ mobile/app/settings.tsx | 10 ++ .../mobile-native-chat-toggle-action.ts | 6 +- .../mobile-terminal-action-sheet-actions.ts | 4 +- .../use-mobile-native-chat-controller.ts | 49 +----- .../use-mobile-session-view-mode.test.ts | 108 ++++++++++++++ .../session/use-mobile-session-view-mode.ts | 85 +++++++++++ mobile/src/storage/preferences.test.ts | 51 +++++-- mobile/src/storage/preferences.ts | 79 ++++++++-- 10 files changed, 461 insertions(+), 75 deletions(-) create mode 100644 mobile/app/native-chat-settings.tsx create mode 100644 mobile/src/session/use-mobile-session-view-mode.test.ts create mode 100644 mobile/src/session/use-mobile-session-view-mode.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0251da70490..af3163fdf70 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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, diff --git a/mobile/app/native-chat-settings.tsx b/mobile/app/native-chat-settings.tsx new file mode 100644 index 00000000000..bc940765e46 --- /dev/null +++ b/mobile/app/native-chat-settings.tsx @@ -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 ( + + + router.back()}> + + + Native chat + + + + DEFAULT VIEW + + 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. + + + + + Open sessions in native chat + {chatDefault ? 'On' : 'Off'} + + + + + + + ) +} + +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 + } +}) diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index b0bd73f651f..7a406ed70ee 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -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() { + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/native-chat-settings')} + > + + Native chat + + + [styles.row, pressed && styles.rowPressed]} onPress={() => router.push('/browser-settings')} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts index 32ae700671a..efdf4f53102 100644 --- a/mobile/src/session/mobile-native-chat-toggle-action.ts +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -11,19 +11,19 @@ type ToggleTab = MobileNativeChatTab & { export function getMobileNativeChatToggleActions(args: { terminalHandle: string | null tabs: readonly ToggleTab[] - chatTabIds: ReadonlySet + 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', diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index 3b29a6dfaf7..e263e32b80f 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -10,7 +10,7 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } export function getMobileTerminalActionSheetActions(args: { target: Target | null tabs: readonly TerminalTab[] - chatTabIds: ReadonlySet + isTabChatView: (tabId: string) => boolean nativeChatTranscriptIsLocalReadable: boolean onDismiss: () => void onToggleChat: (tabId: string) => void @@ -29,7 +29,7 @@ export function getMobileTerminalActionSheetActions + /** 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 @@ -85,46 +84,10 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady, onSendError } = args - const [chatTabIds, setChatTabIds] = useState>(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, diff --git a/mobile/src/session/use-mobile-session-view-mode.test.ts b/mobile/src/session/use-mobile-session-view-mode.test.ts new file mode 100644 index 00000000000..0fc83a3f4ba --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.test.ts @@ -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 + }): Promise { + 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([['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) + }) +}) diff --git a/mobile/src/session/use-mobile-session-view-mode.ts b/mobile/src/session/use-mobile-session-view-mode.ts new file mode 100644 index 00000000000..b5d46a44b9a --- /dev/null +++ b/mobile/src/session/use-mobile-session-view-mode.ts @@ -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>(new Map()) + const viewOverridesToggledRef = useRef(false) + const [defaultView, setDefaultView] = useState('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 } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 881391db0bd..90909385321 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -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', () => { diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index a5af9bf350f..6b2246fae2d 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -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 { +// 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 { 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 { + 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> { + 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).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 ): Promise { 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. } }