mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
|
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
|
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import type { ConnectionState } from '../transport/types'
|
|
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
|
|
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-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 } 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 { 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'
|
|
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
|
|
|
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
|
|
|
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
|
|
|
|
/** 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,
|
|
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
|
|
agent: activeChatResolution?.agent ?? null,
|
|
sessionId: activeChatSessionId,
|
|
transcriptPath: activeChatResolution?.transcriptPath ?? null
|
|
})
|
|
const {
|
|
composerText: chatComposerText,
|
|
setComposerText: setChatComposerText,
|
|
pending: chatPending,
|
|
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
|
|
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,
|
|
transcriptLoading: nativeChatSession.transcriptLoading
|
|
})
|
|
// 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,
|
|
chatImagePreviewsByMessageId,
|
|
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
|
|
}
|
|
}
|