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