import { View, Pressable, Text, ActivityIndicator, Image, type PressableStateCallbackType, } from "react-native"; import type { TFunction } from "i18next"; import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; import { ArrowUp, Square, Pencil, AudioLines, CircleDot, GitPullRequest, Github, Paperclip, } from "lucide-react-native"; import Animated from "react-native-reanimated"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { AgentControls, DraftAgentControls, type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useSessionStore } from "@/stores/session-store"; import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input"; import type { ImageAttachment, MessagePayload } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { cancelComposerAgent, dispatchComposerAgentMessage, editQueuedComposerMessage, findGithubItemByOption, isAttachmentSelectedForGithubItem, openComposerAttachment, pickAndPersistImages, queueComposerMessage, removeComposerAttachmentAtIndex, sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { AutocompletePopover } from "@/components/ui/autocomplete-popover"; import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; import { deleteAttachments, persistAttachmentFromBlob, persistAttachmentFromFileUri, } from "@/attachments/service"; import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; import type { GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata, ComposerAttachment, UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; type QueuedMessage = QueuedComposerMessage; type AttachmentListUpdater = | UserComposerAttachment[] | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); function noop() {} function resolveComposerButtonIconSize(): number { return isWeb ? ICON_SIZE.md : ICON_SIZE.lg; } function resolveIsComposerLocked( submitBehavior: "clear" | "preserve-and-lock", isSubmitLoading: boolean, ): boolean { return submitBehavior === "preserve-and-lock" && isSubmitLoading; } function resolveIsVoiceModeForAgent( voice: ReturnType, serverId: string, agentId: string, ): boolean { return voice?.isVoiceModeForAgent(serverId, agentId) ?? false; } function resolveKeyboardPriority(isMessageInputFocused: boolean): number { return isMessageInputFocused ? 200 : 100; } function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean { return isWeb && !isMobile; } function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean { return override ?? formFactor; } function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string { return isDesktopWebBreakpoint ? t("composer.placeholders.desktop") : t("composer.placeholders.mobile"); } function resolveGithubSearchEnabled( isGithubPickerOpen: boolean, isConnected: boolean, cwd: string, ): boolean { return isGithubPickerOpen && isConnected && cwd.trim().length > 0; } function resolveCheckoutRemoteUrl( checkoutStatus: ReturnType["status"], ): string | null { return checkoutStatus?.remoteUrl ?? null; } function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); } function buildRealtimeVoiceButtonStyle( hovered: boolean | undefined, voiceButtonDisabled: boolean, reserveLeadingSpace: boolean, ): object[] { const hoveredStyle = hovered ? styles.iconButtonHovered : undefined; const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined; const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined; return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter( (value): value is object => Boolean(value), ); } function buildAgentStateSelector(serverId: string, agentId: string) { return (state: ReturnType) => { const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; return { status: agent?.status ?? null, contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, }; }; } function renderContextWindowMeter( contextWindowMaxTokens: number | null, contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, ): ReactElement | null { if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) { return null; } return ( ); } function resolveContextWindowPlacement( meter: ReactElement | null, isMobile: boolean, ): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } { if (isMobile) { return { beforeVoiceContent: null, footerInlineContent: meter }; } return { beforeVoiceContent: {meter}, footerInlineContent: null, }; } interface RenderLeftContentArgs { agentControls: DraftAgentControlsProps | undefined; agentId: string; serverId: string; focusInput: () => void; isCompactLayout: boolean; } function renderLeftContent(args: RenderLeftContentArgs): ReactElement { const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args; if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { return ; } return ( ); } interface RenderAttachmentTrayArgs { selectedAttachments: ComposerAttachment[]; isComposerLocked: boolean; handleOpenAttachment: (attachment: ComposerAttachment) => void; handleRemoveAttachment: (index: number) => void; labels: { openImage: string; removeImage: string; openGithub: (kind: string, number: number) => string; removeGithub: (kind: string, number: number) => string; }; } function renderComposerFooter( footer: ReactNode, footerInlineContent: ReactNode, ): ReactElement | null { if (!footer && !footerInlineContent) return null; return ( {footer} {footerInlineContent} ); } function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, labels, } = args; if (selectedAttachments.length === 0) return null; return ( {selectedAttachments.map((attachment, index) => renderComposerAttachmentPill({ attachment, index, disabled: isComposerLocked, onOpen: handleOpenAttachment, onRemove: handleRemoveAttachment, labels, }), )} ); } interface RenderQueueTrackArgs { queuedMessages: readonly QueuedMessage[]; handleEditQueuedMessage: (id: string) => void; handleSendQueuedNow: (id: string) => Promise; editLabel: string; sendNowLabel: string; } function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel, sendNowLabel } = args; if (queuedMessages.length === 0) return null; return ( {queuedMessages.map((item) => ( ))} ); } interface RenderComposerAttachmentPillArgs { attachment: ComposerAttachment; index: number; disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; labels: RenderAttachmentTrayArgs["labels"]; } function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): ReactElement { const { attachment, index, disabled, onOpen, onRemove, labels } = args; if (attachment.kind === "image") { return ( ); } if (composerWorkspaceAttachment.is(attachment)) { return composerWorkspaceAttachment.renderPill({ attachment, index, disabled, onOpen, onRemove, }); } return ( ); } function resolveVoiceStartErrorMessage(error: unknown): string | null { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return null; } interface AttemptStartRealtimeVoiceArgs { voice: ReturnType; isConnected: boolean; hasAgent: boolean; serverId: string; agentId: string; toastErrorRef: { current: (message: string) => void }; } function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void { const { voice, isConnected, hasAgent, serverId, agentId, toastErrorRef } = args; if (!voice || !isConnected || !hasAgent) return; if (voice.isVoiceSwitching) return; if (voice.isVoiceModeForAgent(serverId, agentId)) return; void voice.startVoice(serverId, agentId).catch((error) => { console.error("[Composer] Failed to start voice mode", error); const message = resolveVoiceStartErrorMessage(error); if (message && message.trim().length > 0) { toastErrorRef.current(message); } }); } function focusMessageInputWithPlatformStrategy(messageInputRef: { current: MessageInputRef | null; }): void { if (isNative) { messageInputRef.current?.focus(); return; } focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { const el = messageInputRef.current?.getNativeElement?.() ?? null; const active = typeof document !== "undefined" ? document.activeElement : null; return Boolean(el) && active === el; }, }); } interface DispatchComposerKeyboardActionArgs { action: KeyboardActionDefinition; isPaneFocused: boolean; messageInputRef: { current: MessageInputRef | null }; isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; handleCancelAgent: () => void; focusMessageInputForKeyboardAction: () => void; } function dispatchComposerKeyboardAction(args: DispatchComposerKeyboardActionArgs): boolean { const { action, isPaneFocused, messageInputRef, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent, focusMessageInputForKeyboardAction, } = args; if (!isPaneFocused) return false; if (action.id === "agent.interrupt") { if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) return true; if (!isAgentRunning || isCancellingAgent || !isConnected) return false; handleCancelAgent(); return true; } if (action.id === "message-input.focus") { focusMessageInputForKeyboardAction(); return true; } const passthroughAction = resolveMessageInputPassthroughAction(action.id); if (!passthroughAction) return false; const result = messageInputRef.current?.runKeyboardAction(passthroughAction); if (passthroughAction === "send" || passthroughAction === "dictation-confirm") { return result ?? false; } return true; } function resolveMessageInputPassthroughAction( actionId: string, ): MessageInputKeyboardActionKind | null { switch (actionId) { case "message-input.send": return "send"; case "message-input.dictation-confirm": return "dictation-confirm"; case "message-input.dictation-toggle": return "dictation-toggle"; case "message-input.dictation-cancel": return "dictation-cancel"; case "message-input.voice-toggle": return "voice-toggle"; case "message-input.voice-mute-toggle": return "voice-mute-toggle"; default: return null; } } interface QueuedMessageRowProps { item: QueuedMessage; onEdit: (id: string) => void; onSendNow: (id: string) => void; editLabel: string; sendNowLabel: string; } function QueuedMessageRow({ item, onEdit, onSendNow, editLabel, sendNowLabel, }: QueuedMessageRowProps) { const handleEdit = useCallback(() => { onEdit(item.id); }, [onEdit, item.id]); const handleSendNow = useCallback(() => { onSendNow(item.id); }, [onSendNow, item.id]); return ( {item.text} ); } function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { const uri = useAttachmentPreviewUrl(image); const source = useMemo(() => ({ uri: uri ?? "" }), [uri]); if (!uri) { return ; } return ; } interface ImageAttachmentPillProps { attachment: Extract; index: number; disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; openLabel: string; removeLabel: string; } function ImageAttachmentPill({ attachment, index, disabled, onOpen, onRemove, openLabel, removeLabel, }: ImageAttachmentPillProps) { const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); const handleRemove = useCallback(() => { onRemove(index); }, [onRemove, index]); return ( ); } interface GithubAttachmentPillProps { attachment: Extract; index: number; disabled: boolean; onOpen: (attachment: ComposerAttachment) => void; onRemove: (index: number) => void; openLabel: (kind: string, number: number) => string; removeLabel: (kind: string, number: number) => string; } function GithubAttachmentPill({ attachment, index, disabled, onOpen, onRemove, openLabel, removeLabel, }: GithubAttachmentPillProps) { const item = attachment.item; const kindLabel = item.kind === "pr" ? "PR" : "issue"; const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); const handleRemove = useCallback(() => { onRemove(index); }, [onRemove, index]); return ( {item.kind === "pr" ? ( ) : ( )} #{item.number} {item.title} ); } interface GithubPickerOptionProps { label: string; testID: string; active: boolean; selected: boolean; item: GitHubSearchItem; onToggle: (item: GitHubSearchItem) => void; } function GithubPickerOption({ label, testID, active, selected, item, onToggle, }: GithubPickerOptionProps) { const handlePress = useCallback(() => { onToggle(item); }, [onToggle, item]); const leadingSlot = useMemo( () => item.kind === "pr" ? ( ) : ( ), [item.kind], ); return ( ); } interface ComposerProps { agentId: string; serverId: string; isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; onClientSlashCommand?: (command: ClientSlashCommand) => Promise; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ hasExternalContent?: boolean; /** When true, the composer can submit even with no text or attachments. */ allowEmptySubmit?: boolean; /** Optional accessibility label for the primary submit button. */ submitButtonAccessibilityLabel?: string; submitIcon?: "arrow" | "return"; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; value: string; onChangeText: (text: string) => void; attachments: UserComposerAttachment[]; workspaceAttachments?: readonly WorkspaceComposerAttachment[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void; cwd: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; /** Callback to expose a focus function to parent components (desktop only). */ onFocusInput?: (focus: () => void) => void; /** Optional draft context for listing commands before an agent exists. */ commandDraftConfig?: DraftCommandConfig; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ onMessageSent?: () => void; onComposerHeightChange?: (height: number) => void; onAttentionInputFocus?: () => void; onAttentionPromptSend?: () => void; /** Controlled agent controls rendered in input area (draft flows). */ agentControls?: DraftAgentControlsProps; /** Extra styles merged onto the message input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; /** Rendered below the input, inside the keyboard-shifted container. */ footer?: ReactNode; /** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */ externalKeyboardShift?: boolean; /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ isCompactLayout?: boolean; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); function resolveContextWindowValues( rawMax: number | null, rawUsed: number | null, ): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } { if (typeof rawMax === "number" && typeof rawUsed === "number") { return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed }; } return { contextWindowMaxTokens: null, contextWindowUsedTokens: null }; } interface ComposerCancelButtonProps { buttonIconSize: number; cancelButtonStyle: (object | undefined)[]; handleCancelAgent: () => void; isConnected: boolean; isCancellingAgent: boolean; agentInterruptKeys: ReturnType; t: TFunction; } function ComposerCancelButton({ buttonIconSize, cancelButtonStyle, handleCancelAgent, isConnected, isCancellingAgent, agentInterruptKeys, t, }: ComposerCancelButtonProps) { const accessibilityLabel = isCancellingAgent ? t("composer.cancel.cancelingAgent") : t("composer.cancel.stopAgent"); const icon = isCancellingAgent ? ( ) : ( ); const shortcutNode = agentInterruptKeys ? : null; return ( {icon} {t("composer.cancel.interrupt")} {shortcutNode} ); } interface ComposerCancelButtonSlotProps extends ComposerCancelButtonProps { isAgentRunning: boolean; hasSendableContent: boolean; isProcessing: boolean; } function ComposerCancelButtonSlot({ isAgentRunning, hasSendableContent, isProcessing, ...rest }: ComposerCancelButtonSlotProps) { if (!isAgentRunning || hasSendableContent || isProcessing) return null; return ; } interface ComposerVoiceModeButtonProps { buttonIconSize: number; handleToggleRealtimeVoice: () => void; isConnected: boolean; isVoiceSwitching: boolean; realtimeVoiceButtonStyle: ( state: PressableStateCallbackType & { hovered?: boolean }, ) => (object | undefined)[]; voiceToggleKeys: ReturnType; t: TFunction; } interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { isVoiceModeForAgent: boolean; hasAgent: boolean; isAgentRunning: boolean; hasSendableContent: boolean; isProcessing: boolean; isCompact: boolean; cancelButton: ReactElement; } function ComposerRightControlsSlot({ isVoiceModeForAgent, hasAgent, isAgentRunning, hasSendableContent, isProcessing, isCompact, cancelButton, ...voiceProps }: ComposerRightControlsSlotProps) { const hideVoiceForCompactInput = isCompact && hasSendableContent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing; if (!showVoiceModeButton && !shouldShowCancelButton) return null; return ( {showVoiceModeButton ? : null} {cancelButton} ); } function ComposerVoiceModeButton({ buttonIconSize, handleToggleRealtimeVoice, isConnected, isVoiceSwitching, realtimeVoiceButtonStyle, voiceToggleKeys, t, }: ComposerVoiceModeButtonProps) { const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { if (isVoiceSwitching) { return ; } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ; }, [buttonIconSize, isVoiceSwitching], ); return ( {renderTriggerContent} {t("composer.voice.voiceMode")} {shortcutNode} ); } export function Composer({ agentId, serverId, isPaneFocused, onSubmitMessage, onClientSlashCommand, hasExternalContent = false, allowEmptySubmit = false, submitButtonAccessibilityLabel, submitIcon = "arrow", isSubmitLoading = false, submitBehavior = "clear", blurOnSubmit = false, value, onChangeText, attachments, workspaceAttachments = [], onOpenWorkspaceAttachment, onChangeAttachments, cwd, clearDraft, autoFocus = false, onAddImages, onFocusInput, commandDraftConfig, onMessageSent, onComposerHeightChange, onAttentionInputFocus, onAttentionPromptSend, agentControls, inputWrapperStyle, footer, externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, }: ComposerProps) { const { t } = useTranslation(); const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const toast = useToast(); const toastErrorRef = useRef(toast.error); toastErrorRef.current = toast.error; const voice = useVoiceOptional(); const voiceToggleKeys = useShortcutKeys("voice-toggle"); const agentInterruptKeys = useShortcutKeys("agent-interrupt"); const isDictationReady = useIsDictationReady({ serverId, isConnected, agentDirectoryStatus, }); const { settings: appSettings } = useAppSettings(); const agentState = useSessionStore(useShallow(buildAgentStateSelector(serverId, agentId))); const queuedMessagesRaw = useSessionStore((state) => state.sessions[serverId]?.queuedMessages?.get(agentId), ); const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); const userInput = value; const setUserInput = onChangeText; const { selectedAttachments, buildOutgoingAttachments, removeAttachment, openAttachment, clearSentAttachments, completeSubmit, resetSuppression, } = composerWorkspaceAttachment.useBinding({ normalAttachments: attachments, workspaceAttachments, onOpenWorkspaceAttachment, }); const setSelectedAttachments = onChangeAttachments; const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); const githubAutoAttach = useComposerGithubAutoAttach({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), attachments, client, isConnected, serverId, cwd, setAttachments: setSelectedAttachments, }); const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); const [githubSearchQuery, setGithubSearchQuery] = useState(""); const [lightboxMetadata, setLightboxMetadata] = useState(null); const attachButtonRef = useRef(null); const messageInputRef = useRef(null); const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading); const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, ); const runClientSlashCommand = useCallback( (command: ClientSlashCommand): boolean => { if (command.execution !== "immediate" || !onClientSlashCommand) { return false; } if (blurOnSubmit) { messageInputRef.current?.blur(); } clearDraft("sent"); setUserInput(""); setSelectedAttachments([]); resetSuppression(); setSendError(null); setIsProcessing(true); void onClientSlashCommand(command) .catch((error) => { console.error("[Composer] Failed to run client slash command:", error); setSendError(error instanceof Error ? error.message : String(error)); }) .finally(() => { setIsProcessing(false); }); return true; }, [ blurOnSubmit, clearDraft, onClientSlashCommand, resetSuppression, setSelectedAttachments, setUserInput, ], ); const autocomplete = useAgentAutocomplete({ userInput, cursorIndex, setUserInput, serverId, agentId, draftConfig: commandDraftConfig, canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, onClientSlashCommand: runClientSlashCommand, onAutocompleteApplied: () => { messageInputRef.current?.focus(); }, }); const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; // Clear send error when user edits the input useEffect(() => { if (sendError && userInput) { setSendError(null); } }, [userInput, sendError]); useEffect(() => { setCursorIndex((current) => Math.min(current, userInput.length)); }, [userInput.length]); const { pickImages } = useImageAttachmentPicker(); const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< ((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise) | null >(null); const onSubmitMessageRef = useRef(onSubmitMessage); // Expose addImages function to parent for drag-and-drop support const addImages = useCallback( (images: ImageAttachment[]) => { setSelectedAttachments((prev) => [ ...prev, ...images.map((metadata) => ({ kind: "image" as const, metadata })), ]); }, [setSelectedAttachments], ); useEffect(() => { onAddImages?.(addImages); }, [addImages, onAddImages]); const focusInput = useCallback(() => { if (isNative) return; focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { const el = messageInputRef.current?.getNativeElement?.() ?? null; return el != null && document.activeElement === el; }, }); }, []); useEffect(() => { onFocusInput?.(focusInput); }, [focusInput, onFocusInput]); const submitMessage = useCallback( async (text: string, submitAttachments: ComposerAttachment[]) => { onMessageSent?.(); if (onSubmitMessageRef.current) { await onSubmitMessageRef.current({ text, attachments: submitAttachments, cwd }); return; } if (!sendAgentMessageRef.current) { throw new Error(t("workspace.terminal.hostDisconnected")); } await sendAgentMessageRef.current(agentIdRef.current, text, submitAttachments); }, [cwd, onMessageSent, t], ); useEffect(() => { agentIdRef.current = agentId; }, [agentId]); useEffect(() => { sendAgentMessageRef.current = async ( targetAgentId: string, text: string, sendAttachments: ComposerAttachment[], ) => { if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); } const stream: AgentStreamWriter = { getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id), setHead: (updater) => setAgentStreamHead(serverId, updater), setTail: (updater) => setAgentStreamTail(serverId, updater), }; await dispatchComposerAgentMessage({ client, agentId: targetAgentId, text, attachments: sendAttachments, encodeImages, stream, }); onAttentionPromptSend?.(); }; }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); const isAgentRunning = agentState.status === "running"; const hasAgent = agentState.status !== null; const queueWriter = useMemo( () => ({ read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], write: (updater) => setQueuedMessages(serverId, updater), }), [serverId, setQueuedMessages], ); const queueMessage = useCallback( (queuedMessage: string, queuedAttachments: ComposerAttachment[]) => { const result = queueComposerMessage({ agentId, text: queuedMessage, attachments: queuedAttachments, queue: queueWriter, }); if (!result.queued) return; setUserInput(""); setSelectedAttachments([]); resetSuppression(); clearSentAttachments(queuedAttachments); }, [ agentId, clearSentAttachments, queueWriter, resetSuppression, setSelectedAttachments, setUserInput, ], ); const sendMessageWithContent = useCallback( async ( outgoingMessage: string, outgoingAttachments: ComposerAttachment[], forceSend?: boolean, ) => { const result = await submitAgentInput({ message: outgoingMessage, attachments: outgoingAttachments, hasExternalContent, allowEmptySubmit, forceSend, submitBehavior, isAgentRunning, // Parent-managed submits are still valid submit paths even when the // transport is disconnected, because the parent decides the failure mode. canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), queueMessage: ({ message: queuedText, attachments: queuedAttachments }) => { queueMessage(queuedText, queuedAttachments); }, submitMessage: async ({ message: submitText, attachments: submitAttachments }) => { await submitMessage(submitText, submitAttachments); }, clearDraft, setUserInput, setAttachments: (nextAttachments) => { setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments)); }, setSendError, setIsProcessing, onSubmitError: (error) => { console.error("[AgentInput] Failed to send message:", error); }, failedToSendMessage: t("composer.errors.failedToSend"), }); completeSubmit({ result, outgoingAttachments, }); }, [ allowEmptySubmit, clearDraft, completeSubmit, hasExternalContent, isAgentRunning, queueMessage, setSelectedAttachments, setUserInput, submitBehavior, submitMessage, t, ], ); const handleSubmit = useCallback( (payload: MessagePayload) => { const outgoingAttachments = buildOutgoingAttachments(attachments); const clientSlashCommand = resolveClientSlashCommand({ text: payload.text, hasAttachments: outgoingAttachments.length > 0, }); if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) { return; } if (blurOnSubmit) { messageInputRef.current?.blur(); } void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend); }, [ attachments, blurOnSubmit, buildOutgoingAttachments, runClientSlashCommand, sendMessageWithContent, ], ); const handlePickImage = useCallback(async () => { const newImages = await pickAndPersistImages({ pickImages, persister: { persistFromBlob: ({ blob, mimeType, fileName }) => persistAttachmentFromBlob({ blob, mimeType, fileName }), persistFromFileUri: ({ uri, mimeType, fileName }) => persistAttachmentFromFileUri({ uri, mimeType, fileName }), }, }); if (newImages.length === 0) return; addImages(newImages); }, [addImages, pickImages]); const handleRemoveAttachment = useCallback( (index: number) => { githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]); const didRemoveWorkspaceAttachment = removeAttachment({ selectedAttachments, index, }); if (didRemoveWorkspaceAttachment) { return; } setSelectedAttachments((prev) => removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), ); }, [githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( (attachment: ComposerAttachment) => { openComposerAttachment({ attachment, setLightboxMetadata, openWorkspaceAttachment: openAttachment, openExternalUrl: (url) => { void openExternalUrl(url); }, }); }, [openAttachment], ); useEffect(() => { if (!isAgentRunning || !isConnected) { setIsCancellingAgent(false); } }, [isAgentRunning, isConnected]); const handleCancelAgent = useCallback(() => { const didCancel = cancelComposerAgent({ client, agentId: agentIdRef.current, isAgentRunning, isCancellingAgent, isConnected, }); if (!didCancel) return; setIsCancellingAgent(true); messageInputRef.current?.focus(); }, [client, isAgentRunning, isCancellingAgent, isConnected]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); }, []); const handleKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => dispatchComposerKeyboardAction({ action, isPaneFocused, messageInputRef, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent, focusMessageInputForKeyboardAction, }), [ focusMessageInputForKeyboardAction, handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused, ], ); useKeyboardActionHandler({ handlerId: keyboardHandlerIdRef.current, actions: [ "agent.interrupt", "message-input.focus", "message-input.send", "message-input.dictation-toggle", "message-input.dictation-cancel", "message-input.dictation-confirm", "message-input.voice-toggle", "message-input.voice-mute-toggle", ], enabled: isPaneFocused, priority: resolveKeyboardPriority(isMessageInputFocused), isActive: () => isPaneFocused, handle: handleKeyboardAction, }); const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate", enabled: !externalKeyboardShift, }); const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId); const handleToggleRealtimeVoice = useCallback(() => { attemptStartRealtimeVoice({ voice, isConnected, hasAgent, serverId, agentId, toastErrorRef, }); }, [agentId, hasAgent, isConnected, serverId, voice]); const handleEditQueuedMessage = useCallback( (id: string) => { const result = editQueuedComposerMessage({ agentId, messageId: id, queue: queueWriter, }); if (!result) return; setUserInput(result.text); setSelectedAttachments(result.attachments); }, [agentId, queueWriter, setSelectedAttachments, setUserInput], ); const handleSendQueuedNow = useCallback( async (id: string) => { if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; // Reuse the regular send path; server-side send atomically interrupts any active run. const result = await sendQueuedComposerMessageNow({ agentId, messageId: id, queue: queueWriter, submitMessage: ({ text, attachments: queuedAttachments }) => submitMessage(text, queuedAttachments), failedToSendMessage: t("composer.errors.failedToSend"), }); if (result.status === "failed") { setSendError(result.errorMessage); } }, [agentId, queueWriter, submitMessage, t], ); const handleQueue = useCallback( (payload: MessagePayload) => { const outgoingAttachments = buildOutgoingAttachments(attachments); const clientSlashCommand = resolveClientSlashCommand({ text: payload.text, hasAttachments: outgoingAttachments.length > 0, }); if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) { return; } queueMessage(payload.text, outgoingAttachments); }, [attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand], ); const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; // Handle keyboard navigation for command autocomplete. const handleCommandKeyPress = useCallback( (event: { key: string; preventDefault: () => void }) => autocompleteOnKeyPressRef.current(event), [], ); const cancelButtonStyle = useMemo( () => buildCancelButtonStyle(isConnected, isCancellingAgent), [isConnected, isCancellingAgent], ); const isVoiceSwitching = voice?.isVoiceSwitching ?? false; const voiceButtonDisabled = !isConnected || isVoiceSwitching; const realtimeVoiceButtonStyle = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout), [isCompactLayout, voiceButtonDisabled], ); const cancelButton = useMemo( () => ( ), [ agentInterruptKeys, buttonIconSize, cancelButtonStyle, handleCancelAgent, hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, isProcessing, t, ], ); const rightContent = useMemo( () => ( ), [ buttonIconSize, cancelButton, handleToggleRealtimeVoice, hasAgent, hasSendableContent, isAgentRunning, isConnected, isCompactLayout, isProcessing, isVoiceModeForAgent, isVoiceSwitching, realtimeVoiceButtonStyle, t, voiceToggleKeys, ], ); const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues( agentState.contextWindowMaxTokens, agentState.contextWindowUsedTokens, ); const contextWindowMeter = useMemo( () => renderContextWindowMeter( contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, ), [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout], ); const { beforeVoiceContent, footerInlineContent } = useMemo( () => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout), [contextWindowMeter, isCompactLayout], ); const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchResultsQuery = useGithubSearchQuery({ client, serverId, cwd, query: githubSearchQueryTrimmed, enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), }); const githubSearchItemsRaw = githubSearchResultsQuery.data?.items; const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]); const githubSearchOptions: ComboboxOption[] = useMemo( () => githubSearchItems.map((item) => ({ id: `${item.kind}:${item.number}`, label: `#${item.number} ${item.title}`, description: githubSearchQueryTrimmed, })), [githubSearchItems, githubSearchQueryTrimmed], ); const attachmentMenuItems = useMemo( () => [ { id: "image", label: t("composer.attachments.addImage"), icon: , onSelect: () => { void handlePickImage(); }, }, { id: "github", label: t("composer.attachments.addIssueOrPr"), icon: , onSelect: () => { setIsGithubPickerOpen(true); }, }, ], [handlePickImage, t], ); const handleToggleGithubItem = useCallback( (item: GitHubSearchItem) => { const nextAttachments = toggleGithubAttachmentFromPicker({ current: attachments, item, markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved, }); setSelectedAttachments(nextAttachments); setIsGithubPickerOpen(false); setGithubSearchQuery(""); }, [ attachments, githubAutoAttach, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen, ], ); const leftContent = useMemo( () => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }), [agentId, focusInput, serverId, agentControls, isCompactLayout], ); const handleAttachButtonRef = useCallback((node: View | null) => { attachButtonRef.current = node; }, []); const handleSelectionChange = useCallback((selection: { start: number; end: number }) => { setCursorIndex(selection.start); }, []); const handleFocusChange = useCallback( (focused: boolean) => { setIsMessageInputFocused(focused); if (focused) { onAttentionInputFocus?.(); } }, [onAttentionInputFocus], ); const handleLightboxClose = useCallback(() => { setLightboxMetadata(null); }, []); const handleGithubPickerOpenChange = useCallback( (open: boolean) => { setIsGithubPickerOpen(open); if (!open) { setGithubSearchQuery(""); } }, [setGithubSearchQuery], ); const renderGithubPickerOption = useCallback( ({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => { const item = findGithubItemByOption(githubSearchItems, option.id); if (!item) { return ; } const selected = isAttachmentSelectedForGithubItem(selectedAttachments, item); return ( ); }, [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); const composerContainerStyle = useMemo( () => [styles.container, keyboardAnimatedStyle], [keyboardAnimatedStyle], ); const inputAreaContainerStyle = useMemo( () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], [isComposerLocked], ); const attachmentTray = useMemo( () => renderAttachmentTray({ selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, labels: { openImage: t("composer.attachments.openImage"), removeImage: t("composer.attachments.removeImage"), openGithub: (kind: string, number: number) => t("composer.attachments.openGithub", { kind, number }), removeGithub: (kind: string, number: number) => t("composer.attachments.removeGithub", { kind, number }), }, }), [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t], ); const queueList = useMemo( () => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow, editLabel: t("composer.attachments.editQueuedMessage"), sendNowLabel: t("composer.attachments.sendQueuedMessageNow"), }), [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t], ); const messageInputContainerRef = useRef(null); const isSubmitBusy = isProcessing || isSubmitLoading; const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( () => (sendError ? {sendError} : null), [sendError], ); const githubEmptyText = githubSearchResultsQuery.isFetching ? t("composer.github.searching") : t("composer.github.noResults"); const autocompleteVisible = autocomplete.isVisible && isPaneFocused; return ( {/* Input area */} {queueList} {sendErrorNode} {/* MessageInput handles everything: text, dictation, attachments, all buttons */} {renderComposerFooter(footer, footerInlineContent)} ); } const styles = StyleSheet.create((theme: Theme) => ({ container: { flexDirection: "column", position: "relative", }, borderSeparator: { height: theme.borderWidth[1], backgroundColor: theme.colors.border, }, inputAreaContainer: { position: "relative", minHeight: FOOTER_HEIGHT, marginHorizontal: "auto", alignItems: "center", width: "100%", overflow: "visible", paddingHorizontal: theme.spacing[4], paddingBottom: theme.spacing[4], }, inputAreaLocked: { opacity: 0.6, }, inputAreaContent: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, gap: theme.spacing[3], }, footer: { width: "100%", paddingHorizontal: theme.spacing[4], // Negative margin pulls the footer up against the input area's paddingBottom. // On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more. marginTop: { xs: -(theme.spacing[4] - 3), md: -theme.spacing[3], }, alignItems: "center", paddingBottom: { xs: 0, md: theme.spacing[2], }, }, footerContent: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, flexDirection: "row", alignItems: "center", justifyContent: "space-between", // On mobile, the negative margins below cancel each glyph's internal padding // to reach the composer border; this inset adds a small visual gap from it. paddingLeft: { xs: 5, md: 10, }, paddingRight: { xs: 5, md: 10, }, }, footerLeft: { flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], // On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal) // so its icon aligns to the composer border before the footer inset is applied. marginLeft: { xs: -theme.spacing[2], md: 0, }, }, messageInputContainer: { position: "relative", width: "100%", gap: theme.spacing[3], }, cancelButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, rightControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, contextWindowMeterSlot: { width: 28, height: 28, alignItems: "center", justifyContent: "center", }, realtimeVoiceButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, realtimeVoiceButtonCompactReserve: { marginLeft: theme.spacing[1], }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], borderColor: theme.colors.palette.green[800], }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, attachmentTray: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", }, imageThumbnail: { width: 32, height: 32, }, imageThumbnailPlaceholder: { width: 32, height: 32, backgroundColor: theme.colors.surface2, }, githubPillBody: { minHeight: 32, maxWidth: 260, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, }, githubPillIcon: { width: 18, alignItems: "center", justifyContent: "center", }, githubPillText: { minWidth: 0, flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, tooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, buttonDisabled: { opacity: 0.5, }, queueTrack: { flexDirection: "column", gap: theme.spacing[2], }, queueItem: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, gap: theme.spacing[2], }, queueText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base, }, queueActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, queueActionButton: { width: 32, height: 32, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.surface2, }, queueSendButton: { backgroundColor: theme.colors.accent, }, sendErrorText: { color: theme.colors.palette.red[500], fontSize: theme.fontSize.sm, }, })) as unknown as Record; const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton]; const ThemedPencil = withUnistyles(Pencil); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedAudioLines = withUnistyles(AudioLines); const ThemedPaperclip = withUnistyles(Paperclip); const ThemedGithub = withUnistyles(Github); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });