From 0dfb2cbd225e80ebb57aafb99e1a015f1241f226 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:42:17 -0700 Subject: [PATCH] refactor(mobile): separate native chat controller contract --- .../mobile-native-chat-controller-contract.ts | 64 +++++++++++++++ .../use-mobile-native-chat-controller.ts | 82 ++----------------- 2 files changed, 69 insertions(+), 77 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-controller-contract.ts diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts new file mode 100644 index 00000000000..a817b47b3ee --- /dev/null +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -0,0 +1,64 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { detectAgentPermission } from './mobile-native-chat-permission' +import type { parseAgentQuestion } from './mobile-native-chat-question' +import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' + +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 + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record + nativeChatSession: ReturnType + 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 + nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ + nativeChatAsk: ReturnType + /** 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 + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatQuestionAnswer: (text: string) => Promise + handleNativeChatSend: (text: string, images?: string[]) => Promise + /** 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 + /** 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 +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index eaa36fc5051..46012dadbe4 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,101 +1,29 @@ -import { - useCallback, - useLayoutEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +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 { - 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 { 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 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' +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 -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 - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - chatImagePreviewsByMessageId: Record - nativeChatSession: ReturnType - 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 - nativeChatQuestion: ReturnType - /** The pending ask, already null while dismissed (dismissal lives here so it - * survives the chat-view subtree unmounting on a view toggle). */ - nativeChatAsk: ReturnType - /** 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 - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** 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 - /** 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: {