Files
orca/mobile/src/session/MobileNativeChatOverlay.tsx
T
Brennan Benson e2f2277d51 feat(native-chat): one queue header that becomes the pause row; drop the composer Resume and the send dialog (#28654)
* feat(native-chat): one queue header that becomes the pause row; no composer Resume or send dialog

The queued cards now sit under one header row on desktop and phone: "Queued"
and the count normally, and "Paused" with the count, Clear all and Resume
while the host holds the queue after a Stop. A chevron folds only the cards;
the choice is local to the view and resets when the queue empties.

The composer's Send no longer turns into a second Resume, and a message sent
into a paused queue no longer asks "Send message?": it sends, its accepted
turn lifts the pause, and the cards follow it. Clear all deletes every card
through the existing per-card delete. No wire or host change.

* test(mobile): find the background-tasks strip's toggle inside the strip, not the queue header's

* Make queued Clear all atomic across clients

* Show queued message failures in collapsed headers

* Use per-card delete for paused queue Clear all

* Join repeated paused queue Clear actions
2026-10-15 15:37:21 -07:00

168 lines
7.1 KiB
TypeScript

import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray'
import { useMobileNativeChatVisualRenderer } from './MobileNativeChatVisual'
import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context'
type Props = {
controller: MobileNativeChatController
/** Opens a tapped file reference (worktree-relative or absolute, optional
* :line(:col) suffix) through the shared tap-to-open flow. */
onOpenFile: (pathText: string) => void
/** Native-chat image attachments: picking adds a composer chip, and sending
* rides the pending images along with the message text (desktop parity). */
images: MobileNativeChatImageAttachments
onMicPress: () => void
micActive: boolean
dictationMode: string | undefined
onMicPressIn: () => void
onMicPressOut: () => void
inputLockReason: MobileNativeChatInputLockReason | null
/** Latest send failure, rendered inline above the composer. */
sendErrorMessage: string | null
/** Drops that failure once a later send succeeds. */
onClearSendError: () => void
/** Stable host/worktree/tab identity for accepted-send completion fencing. */
sendSurfaceId: string
/** Reads the retained route's focus generation for accepted-send fencing. */
getSendCompletionGeneration: () => number
keyboardInset: number
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
* view toggles while the native surface owns the visible composer. Also owns
* the streaming gate: this component stays mounted across those toggles, while
* the chat list below it does not. */
export function MobileNativeChatOverlay({
controller,
onOpenFile,
images,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
sendErrorMessage,
onClearSendError,
sendSurfaceId,
getSendCompletionGeneration,
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
// The chat is drawn only with a resolved agent; the fallback never reaches the screen.
const agent = controller.nativeChatAgent ?? ''
const folded = useMemo(
() => foldMobileNativeChatMessages(session.messages, agent),
[session.messages, agent]
)
const streaming = useMobileNativeChatStreamingBubble(
folded,
controller.nativeChatStreamingText,
controller.nativeChatStreamScopeKey,
controller.nativeChatStreamLive
)
const queued = controller.nativeChatQueued
const composerTray = useMobileNativeChatComposerTray({
queued: {
cards: queued.cards,
onSend: queued.send,
onDelete: queued.delete,
onClearAll: queued.clear,
onEdit: queued.edit,
pause: queued.pause,
onResume: queued.resume,
sessionKey: queued.sessionKey,
// Nothing steers into a turn a Stop is ending; the host holds such a send until it ends.
// The indicator's `stopping` is the display status, decided once in the session hook.
steerHeld: controller.nativeChatTurnIndicator?.stopping === true
},
backgroundTasks: controller.nativeChatBackgroundTasks
})
const visuals = useMobileNativeChatVisualRenderer(controller.nativeChatVisualSource)
if (!controller.showNativeChat) {
return null
}
return (
<View style={styles.overlay}>
<MobileNativeChatVisualContext.Provider value={visuals}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
turnJournal={controller.nativeChatTurnJournal}
receipts={controller.nativeChatReceipts}
actionablePromptIds={controller.nativeChatActionablePromptIds}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
composerTray={composerTray}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={
controller.nativeChatAcceptsImages
? () => void images.attachImage('library')
: undefined
}
attachments={images.attachments}
onRemoveAttachment={images.removeAttachment}
isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
keyboardInset={keyboardInset}
/>
</MobileNativeChatVisualContext.Provider>
</View>
)
}
const styles = StyleSheet.create({
overlay: StyleSheet.absoluteFillObject
})