mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +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. * 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
278 lines
9.1 KiB
TypeScript
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
|
|
}
|
|
}
|