mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user