Files
orca/mobile/src/session/use-mobile-native-chat-controller.ts
T
Brennan Benson 44686f324c 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
2026-07-17 00:34:46 -07:00

278 lines
9.1 KiB
TypeScript

import {
useCallback,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import {
parseAskFromStatus,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useThrottledLatestValue } from './use-throttled-latest-value'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
/** 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>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: Array<{ id: string; text: string }>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatSend: (text: string) => Promise<boolean>
}
/** Owns mobile native-chat state and teardown outside the already dense session
* route. The route remains responsible only for choosing and rendering the view. */
export function useMobileNativeChatController(args: {
client: RpcClient | null
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
nativeChatInputLeaseReady: boolean
onSendError: (message: string) => void
}): MobileNativeChatController {
const {
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError
} = args
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
const activeChatResolution =
activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
showNativeChatRef.current = showNativeChat
const activeChatAgentRef = useRef<string | null>(activeChatResolution?.agent ?? null)
activeChatAgentRef.current = activeChatResolution?.agent ?? null
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
})
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
pending: chatPending,
captureSendOrigin,
acceptSend
} = useMobileNativeChatDrafts({
hostId,
worktreeId,
tabId: activeSessionTabId,
sessionId: activeChatSessionId,
messages: nativeChatSession.messages
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined,
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
permission: nativeChatPermission,
question: nativeChatQuestion,
ask: nativeChatAsk
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
})
const handleNativeChatOpenFile = useCallback(
(pathText: string) => {
if (!client) {
return
}
void openMobileNativeChatFile({
client,
worktreeId,
pathText,
terminal: activeHandleRef.current
})
},
[activeHandleRef, client, worktreeId]
)
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
onSendError
})
const handleNativeChatCancelAsk = useCallback(async (): Promise<boolean> => {
const handle = activeHandleRef.current
if (!client || !handle || !nativeChatInputLeaseReady) {
onSendError('Cancel not sent (disconnected)')
return false
}
cancelNativeChatAnswer()
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text: String.fromCharCode(27),
enter: false,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Cancel not sent')
}
return accepted
}, [
activeHandleRef,
cancelNativeChatAnswer,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
])
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
})
const handleNativeChatStop = useMobileNativeChatStop({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
streamIdentity,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({
client,
worktreeId
})
const handleNativeChatSend = useCallback(
async (text: string): Promise<boolean> => {
const handle = activeHandleRef.current
const origin = captureSendOrigin(text)
if (!client || !handle || !origin || !nativeChatInputLeaseReady) {
onSendError('Message not sent (disconnected)')
return false
}
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Message not sent')
return false
}
acceptSend(origin, text)
return true
},
[
acceptSend,
activeHandleRef,
captureSendOrigin,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
]
)
return {
isTabChatView,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
nativeChatAgent: activeChatResolution?.agent ?? null,
chatComposerText,
setChatComposerText,
chatPending,
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
handleNativeChatOpenFile,
handleNativeChatAnswerAsk,
handleNativeChatCancelAsk,
handleNativeChatRespondPermission,
handleNativeChatStop,
nativeChatFilePaths,
loadNativeChatFiles,
handleNativeChatSend
}
}