mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
* fix(mobile): keep native chat ask dismissals tab-scoped and gated
Dismissal state lived in the chat view subtree, which unmounts on a
chat<->terminal toggle, so an answered ask card came back on return. It
also had no tab scope and no waiting/blocked gate.
- move dismissal into the controller, keyed per session tab
- gate ask cards on waiting/blocked like the permission path already is,
and retire a dismissal off the ungated detected prompt so a working/done
status can't be mistaken for the prompt clearing
- ignore a dismissal that settles after its prompt cleared or was replaced
Refs STA-3333.
* fix(mobile): keep an ask dismissal through the transcript re-subscribe
A view toggle or tab switch re-subscribes the native-chat transcript, and
useMobileNativeChatSession withholds `messages` until that read settles. A
transcript-derived ask therefore reads as null while the chat surface is
already visible, so the reset effect took it as "the agent moved on" and
retired a live dismissal — the answered card came back, which is the bug
the off-chat guard was meant to close.
Treat an unobserved null as unobserved: `observing` now also requires the
read to have settled. A prompt that is already detected stays observable on
its own, so a status-derived ask still registers on first paint and an
answer taken during that first load is still accepted.
* fix(mobile): keep the transcript-derived ask outside the paused gate
A hook row idle past AGENT_STATUS_STALE_AFTER_MS (30m) projects to `done`
with no interactivePrompt, so the transcript fallback is the only source
left for a still-pending question. Gating it behind waiting/blocked made
that question unanswerable from mobile. Only the sticky status payload
needs the gate; `extractPendingAsk` clears itself on the tool result.
Also pins the load-window clause in the ask-observability guard, which
was behaviourally load-bearing but killed no test.
* fix(mobile): treat a never-read transcript as unobserved, not as "no ask"
The ask-observability guard only excused `transcriptLoading`, which is true
for an in-flight read alone. useMobileNativeChatSession also withholds
`messages` when the client is gone ('idle') or the tab has not reported a
provider session yet ('waiting-session') — both leave the flag false over an
empty list that was never read. The derived prompt then read as null, the
reset effect took that as "the agent moved on", and a live dismissal was
retired; when the read landed with the question still pending the answered
card came back — the resurfacing bug this guard exists to close.
Gate on the read having actually settled instead. 'error' still counts: it
keeps the last successful read in `messages`, so a prompt that clears under
it is real evidence, unlike a list that was never populated.
Also locks three guards that killed no test: the sticky-status suppression
of the transcript fallback (which is what makes the new paused gate hold in
the post-answer window), the reset effect's identity bail-out, and showAsk's
empty-prompt case. The transcript stand-in now derives `transcriptLoading`
from `status` the way the real hook couples them, so these tests can only
express states the session hook can reach.
Refs STA-3333.
* test(mobile): pin the ask dismissal's tab scope and ungated retirement input
Both wirings were unpinned: swapping `scopeKey` to a constant or feeding the
gated `ask` in as `detectedAsk` left the whole mobile suite green.
* fix(mobile): require a landed read before an errored transcript retires a dismissal
`status === 'error'` was treated as settled on the claim that an error keeps
the last successful read in `messages`. That only holds for an error that lands
on top of an earlier read. The host forwards an initial-drain failure as an
error frame carrying an EMPTY list (transcript-watch-error.test.ts), the mobile
frame applier checks `frame.error` before the messages array so those rows are
discarded, and the session hook's error path never calls `setMessages` — so a
first-read error leaves `messages` at the `[]` the identity-change effect wrote.
That frame is also not terminal: the watcher keeps `initialDrain` true and a
real snapshot follows once the read recovers. So a re-subscribe whose first
read errors made the never-populated list read as "no ask", retired the live
dismissal, and the recovered snapshot brought the answered card back over the
composer — the exact resurfacing this guard exists to close, and most likely on
remote/SSH transcript reads.
Require rows for the error case. Rows can only be present once a read landed,
so the predicate is never wrong in the resurfacing direction; it only declines
to retire a dismissal when the transcript was never observed.
Also drop the dismiss hook's `detectedAsk = ask` default and make both prompts
required. That default silently fed the gated prompt in as the detected one,
which is the pre-fix behavior: a paused-out card would read as "prompt gone"
and retire the dismissal. tsc now enforces the ungated payload at every call
site instead of leaving a trap for the next caller.
* fix(mobile): scope the ask dismissal to the provider session, not the tab
A restart, /clear, or resume swaps the provider session inside one tab. The
next session's first question is often byte-identical, so a tab-keyed dismissal
hid the live card and left the turn blocked with nothing to act on.
* chore: restore upstream formatting
356 lines
14 KiB
TypeScript
356 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
|
|
})
|
|
|
|
// A Codex-style model change happens in the agent's own TUI picker — bring
|
|
// the terminal view forward so the dispatched `/model` selector is visible.
|
|
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
|
|
}
|
|
}
|