mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
* fix(native-chat): select Codex models directly * fix(native-chat): confirm agent exits before switching views * fix(runtime): handle unavailable foreground probes
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import {
|
|
useCallback,
|
|
useLayoutEffect,
|
|
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 type { ConnectionState } from '../transport/types'
|
|
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 { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
|
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
|
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
|
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
|
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
|
import {
|
|
useMobileNativeChatDrafts,
|
|
type MobileNativeChatPendingMessage
|
|
} from './use-mobile-native-chat-drafts'
|
|
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
|
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
|
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
|
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
|
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
|
|
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
|
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
|
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
|
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
|
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: MobileNativeChatPendingMessage[]
|
|
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
|
nativeChatAgentWorking: boolean
|
|
nativeChatStreamingText?: string
|
|
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
|
nativeChatStreamLive: boolean
|
|
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
|
nativeChatStreamScopeKey: string
|
|
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
|
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
|
/** The pending ask, already null while dismissed (dismissal lives here so it
|
|
* survives the chat-view subtree unmounting on a view toggle). */
|
|
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
|
/** Stable key for the current ask card (keys the card component). */
|
|
nativeChatAskKey: string | null
|
|
/** Hide the current ask until a genuinely different question arrives. */
|
|
dismissNativeChatAsk: () => void
|
|
handleNativeChatAnswerAsk: (
|
|
prompt: AskPrompt,
|
|
selections: AskAnswerSelection[]
|
|
) => Promise<boolean>
|
|
handleNativeChatCancelAsk: () => Promise<boolean>
|
|
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
|
handleNativeChatStop: () => void
|
|
nativeChatFilePaths: string[]
|
|
loadNativeChatFiles: (query: string) => void
|
|
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
|
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
|
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
|
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
|
* caller passes its own `deadline` so the paste it already spent and this text
|
|
* body share one budget instead of holding the composer for two. */
|
|
handleNativeChatSendWithOutcome: (
|
|
text: string,
|
|
images?: string[],
|
|
deadline?: number
|
|
) => Promise<MobileNativeChatSendOutcome>
|
|
/** Launch-context text still parked on the agent's TUI input line, or null.
|
|
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
|
readSeededLaunchDraft: () => string | null
|
|
/** Model/session-option pickers for the composer, or null when the active
|
|
* agent has no session-option catalog. */
|
|
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
|
}
|
|
|
|
/** 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
|
|
/** Live socket state; the lease collapses on disconnect but one render later. */
|
|
connState: ConnectionState
|
|
onSendError: (message: string) => void
|
|
/** Retires a held failure banner. Any accepted chat write clears it — a delivered
|
|
* answer or permission reply must not sit under a stale "not sent". */
|
|
onSendResolved: () => void
|
|
}): MobileNativeChatController {
|
|
const {
|
|
client,
|
|
hostId,
|
|
worktreeId,
|
|
activeSessionTab,
|
|
activeSessionTabId,
|
|
activeHandleRef,
|
|
deviceTokenRef,
|
|
nativeChatTranscriptIsLocalReadable,
|
|
nativeChatInputLeaseReady,
|
|
connState,
|
|
onSendError,
|
|
onSendResolved
|
|
} = 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)
|
|
const activeChatAgent = activeChatResolution?.agent ?? null
|
|
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
|
|
useLayoutEffect(() => {
|
|
showNativeChatRef.current = showNativeChat
|
|
activeChatAgentRef.current = activeChatAgent
|
|
}, [activeChatAgent, showNativeChat])
|
|
|
|
const activeChatSessionId = activeChatResolution?.sessionId ?? null
|
|
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
|
|
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
|
// Same chat, but keyed off the tab rather than the view-gated resolution:
|
|
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
|
|
// and a scope that flips on a view toggle throws the gate's baseline away.
|
|
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\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,
|
|
readSeededLaunchDraft,
|
|
readSeededLaunchDraftSeed,
|
|
clearDraftForSend,
|
|
restoreRejectedDraft,
|
|
acceptSend,
|
|
holdUnconfirmedSend
|
|
} = useMobileNativeChatDrafts({
|
|
hostId,
|
|
worktreeId,
|
|
tabId: activeSessionTabId,
|
|
sessionId: activeChatSessionId,
|
|
messages: nativeChatSession.messages,
|
|
launchDraft: activeSessionTab?.launchDraft ?? null,
|
|
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null,
|
|
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
|
|
// a null is indistinguishable from a host retraction, and peeking at the
|
|
// terminal view would permanently decline the prefill.
|
|
chatActive: showNativeChat,
|
|
transcriptLoading: nativeChatSession.transcriptLoading
|
|
})
|
|
|
|
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
|
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
|
|
// Deliberately not gated on the chat view being visible: the streaming gate
|
|
// has to tell "hidden mid-turn" from "the turn ended".
|
|
const nativeChatStreamLive = activeSessionTab?.agentStatus?.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,
|
|
detectedAsk: nativeChatDetectedAsk,
|
|
ask: nativeChatAskPrompt
|
|
} = useMobileNativeChatPrompts({
|
|
enabled: activeChatResolution != null,
|
|
status: nativeChatStatus,
|
|
messages: nativeChatSession.messages
|
|
})
|
|
// A never-read transcript cannot prove that a dismissed prompt cleared.
|
|
const nativeChatTranscriptSettled =
|
|
nativeChatSession.status === 'ready' ||
|
|
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
|
|
const nativeChatAskObservable =
|
|
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
|
const {
|
|
askKey: nativeChatAskKey,
|
|
showAsk: showNativeChatAsk,
|
|
dismissAsk: dismissNativeChatAsk
|
|
} = useMobileNativeChatAskDismiss({
|
|
ask: nativeChatAskPrompt,
|
|
detectedAsk: nativeChatDetectedAsk,
|
|
scopeKey: activeSessionTabId,
|
|
sessionKey: activeChatSessionId,
|
|
observing: nativeChatAskObservable
|
|
})
|
|
|
|
// Every chat write gates on both: the lease proves the input floor is ours, and
|
|
// `connState` collapses a render before the lease does on disconnect.
|
|
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
|
|
|
|
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
|
|
useMobileNativeChatAnswerSend({
|
|
client,
|
|
enabled: inputSendable,
|
|
handleRef: activeHandleRef,
|
|
deviceTokenRef,
|
|
agentRef: activeChatAgentRef,
|
|
sessionId: activeChatSessionId,
|
|
streamIdentity,
|
|
onSendError
|
|
})
|
|
|
|
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
|
|
client,
|
|
enabled: inputSendable,
|
|
handleRef: activeHandleRef,
|
|
deviceTokenRef,
|
|
cancelPending: cancelNativeChatAnswer,
|
|
onSendError
|
|
})
|
|
|
|
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
|
|
client,
|
|
enabled: inputSendable,
|
|
handleRef: activeHandleRef,
|
|
deviceTokenRef,
|
|
onSendError
|
|
})
|
|
|
|
const handleNativeChatStop = useMobileNativeChatStop({
|
|
client,
|
|
enabled: inputSendable,
|
|
handleRef: activeHandleRef,
|
|
deviceTokenRef,
|
|
streamIdentity,
|
|
cancelPending: cancelNativeChatAnswer,
|
|
onSendError
|
|
})
|
|
|
|
const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({
|
|
client,
|
|
worktreeId
|
|
})
|
|
|
|
// Why: the send seam reports outgoing catalog commands to session-option
|
|
// tracking, but the options hook needs the seam's dispatcher — a ref breaks
|
|
// the cycle without re-creating the send callbacks per snapshot.
|
|
const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {})
|
|
|
|
const {
|
|
send: handleNativeChatSend,
|
|
sendWithOutcome: handleNativeChatSendWithOutcome,
|
|
answerQuestion: handleNativeChatQuestionAnswer,
|
|
dispatchCommand: handleNativeChatDispatchCommand
|
|
} = useMobileNativeChatMessageSend({
|
|
client,
|
|
enabled: inputSendable,
|
|
handleRef: activeHandleRef,
|
|
deviceTokenRef,
|
|
agentRef: activeChatAgentRef,
|
|
commandSendRef: recordSessionOptionCommandRef,
|
|
captureSendOrigin,
|
|
readSeededLaunchDraftSeed,
|
|
clearDraftForSend,
|
|
restoreRejectedDraft,
|
|
acceptSend,
|
|
holdUnconfirmedSend,
|
|
onSendError
|
|
})
|
|
|
|
// Bring the terminal view forward when an agent-owned picker command is used.
|
|
const handleAgentPicker = useCallback(() => {
|
|
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
|
|
toggleTabChatView(activeSessionTabId)
|
|
}
|
|
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
|
|
|
|
const sessionOptions = useMobileNativeChatSessionOptions({
|
|
agent: activeChatResolution?.agent ?? null,
|
|
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
|
|
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
|
|
dispatchCommand: handleNativeChatDispatchCommand,
|
|
onAgentPicker: handleAgentPicker
|
|
})
|
|
useLayoutEffect(() => {
|
|
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
|
|
}, [sessionOptions.recordCommand])
|
|
// Card actions retire the route's held failure banner too, not just sends.
|
|
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
|
|
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
|
|
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
|
|
|
|
return {
|
|
isTabChatView,
|
|
toggleTabChatView,
|
|
showNativeChat,
|
|
showNativeChatRef,
|
|
nativeChatAgent: activeChatResolution?.agent ?? null,
|
|
chatComposerText,
|
|
setChatComposerText,
|
|
chatPending,
|
|
nativeChatSession,
|
|
nativeChatAgentWorking,
|
|
nativeChatStreamingText,
|
|
nativeChatStreamLive,
|
|
nativeChatStreamScopeKey: streamScopeKey,
|
|
nativeChatPermission,
|
|
nativeChatQuestion,
|
|
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
|
|
nativeChatAskKey,
|
|
dismissNativeChatAsk,
|
|
handleNativeChatAnswerAsk: answerAsk,
|
|
handleNativeChatCancelAsk: cancelAsk,
|
|
handleNativeChatRespondPermission: respond,
|
|
handleNativeChatStop,
|
|
nativeChatFilePaths,
|
|
loadNativeChatFiles,
|
|
handleNativeChatQuestionAnswer,
|
|
handleNativeChatSend,
|
|
handleNativeChatSendWithOutcome,
|
|
readSeededLaunchDraft,
|
|
nativeChatSessionOptions:
|
|
sessionOptions.snapshot.length > 0
|
|
? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
|
|
: null
|
|
}
|
|
}
|