diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index b7c9390c6..5ea5261ae 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -706,7 +706,7 @@ function AgentScreenContent({ {/* Agent Input Area */} {!isInitializing && agent && resolvedAgentId && ( - + )} diff --git a/packages/app/src/app/agent/new.tsx b/packages/app/src/app/agent/new.tsx index be05705c1..9c206a316 100644 --- a/packages/app/src/app/agent/new.tsx +++ b/packages/app/src/app/agent/new.tsx @@ -663,7 +663,7 @@ export default function DraftAgentScreen() { pendingRequestIdRef.current = null; setIsLoading(false); setPromptText(""); - router.push({ + router.replace({ pathname: "/agent/[serverId]/[agentId]", params: { serverId: selectedServerId, @@ -980,6 +980,7 @@ export default function DraftAgentScreen() { isSubmitLoading={isLoading} value={promptText} onChangeText={setPromptText} + autoFocus /> diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 24d13e978..2314e189a 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,97 +1,55 @@ import { View, - TextInput, Pressable, - NativeSyntheticEvent, - TextInputContentSizeChangeEventData, - TextInputKeyPressEventData, - Image, Platform, Text, ActivityIndicator, } from "react-native"; -import { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native"; +import { ArrowUp, AudioLines, Square, Pencil } from "lucide-react-native"; import Animated, { - useSharedValue, useAnimatedStyle, - withTiming, FadeIn, FadeOut, } from "react-native-reanimated"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useRealtime } from "@/contexts/realtime-context"; -import { useDictation } from "@/hooks/use-dictation"; import { FOOTER_HEIGHT } from "@/constants/layout"; -import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay"; -import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice"; import { generateMessageId } from "@/types/stream"; import { AgentStatusBar } from "./agent-status-bar"; import { RealtimeControls } from "./realtime-controls"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; -import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; import { useSessionStore } from "@/stores/session-store"; +import { MessageInput, type MessagePayload, type ImageAttachment } from "./message-input"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; -import type { SessionOutboundMessage } from "@server/server/messages"; type QueuedMessage = { id: string; text: string; - images?: Array<{ uri: string; mimeType: string }>; + images?: ImageAttachment[]; }; interface AgentInputAreaProps { agentId: string; serverId: string; - onSubmitMessage?: (payload: { - text: string; - images?: Array<{ uri: string; mimeType: string }>; - }) => Promise; + onSubmitMessage?: (payload: MessagePayload) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; value?: string; onChangeText?: (text: string) => void; + /** When true, auto-focuses the text input on web. */ + autoFocus?: boolean; } -const MIN_INPUT_HEIGHT = 50; -const MAX_INPUT_HEIGHT = 160; const EMPTY_ARRAY: readonly QueuedMessage[] = []; const MAX_INPUT_WIDTH = 960; -const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2; // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated // entering/exiting animations (see react-native-reanimated#8422), so guard them. const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android"; const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250); const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250); -const IS_WEB = Platform.OS === "web"; -const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB; -type WebTextInputKeyPressEvent = NativeSyntheticEvent< - TextInputKeyPressEventData & { - metaKey?: boolean; - ctrlKey?: boolean; - shiftKey?: boolean; - } ->; -type TextAreaHandle = { - scrollHeight?: number; - style?: { - height?: string; - overflowY?: string; - } & Record; -}; - -type DictationToastConfig = { - variant: DictationToastVariant; - title: string; - subtitle?: string; - meta?: string; - actionLabel?: string; - onAction?: () => void; - onDismiss?: () => void; -}; export function AgentInputArea({ agentId, @@ -100,6 +58,7 @@ export function AgentInputArea({ isSubmitLoading = false, value, onChangeText, + autoFocus = false, }: AgentInputAreaProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -125,7 +84,7 @@ export function AgentInputArea({ const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - // Noop fallback for sendAgentAudio (required by useDictation) + // Noop fallback for sendAgentAudio (required by MessageInput) const noopSendAgentAudio = useCallback(async () => {}, []); const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio; @@ -149,30 +108,21 @@ export function AgentInputArea({ const wsOrInert = ws ?? inertWebSocket; const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); - + const [internalInput, setInternalInput] = useState(""); const userInput = value ?? internalInput; const setUserInput = onChangeText ?? setInternalInput; - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isProcessing, setIsProcessing] = useState(false); - const [selectedImages, setSelectedImages] = useState>([]); + const [selectedImages, setSelectedImages] = useState([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); - const [audioDebugInfo, setAudioDebugInfo] = useState(null); - const [connectionStatus, setConnectionStatus] = useState(() => - wsOrInert.getConnectionState ? wsOrInert.getConnectionState() : { isConnected: wsOrInert.isConnected, isConnecting: wsOrInert.isConnecting } - ); - - const textInputRef = useRef unknown }) | null>(null); - const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const baselineInputHeightRef = useRef(null); - const overlayTransition = useSharedValue(0); + const { pickImages } = useImageAttachmentPicker(); - const shouldShowAudioDebug = AUDIO_DEBUG_ENABLED; const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef(sendAgentMessage); const onSubmitMessageRef = useRef(onSubmitMessage); + const submitMessage = useCallback( - async (text: string, images?: Array<{ uri: string; mimeType: string }>) => { + async (text: string, images?: ImageAttachment[]) => { if (onSubmitMessageRef.current) { await onSubmitMessageRef.current({ text, images }); return; @@ -181,158 +131,6 @@ export function AgentInputArea({ }, [] ); - const handleDictationTranscript = useCallback( - (text: string) => { - if (!text) { - return; - } - const current = userInput; - const shouldPad = current.length > 0 && !/\s$/.test(current); - const nextValue = `${current}${shouldPad ? " " : ""}${text}`; - setUserInput(nextValue); - if (IS_WEB && typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => { - measureWebInputHeight("dictation"); - }); - } - }, - [setUserInput, userInput, measureWebInputHeight] - ); - - const handleDictationError = useCallback((error: Error) => { - console.error("[AgentInput] Dictation error:", error); - }, []); - - const canStartDictation = useCallback(() => { - const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; - const allowed = !isRealtimeMode && socketConnected && (Boolean(agent) || Boolean(onSubmitMessage)); - console.log("[AgentInput] canStartDictation", { - allowed, - isRealtimeMode, - wsConnected: socketConnected, - }); - return allowed; - }, [agent, isRealtimeMode, onSubmitMessage, wsOrInert]); - - const canConfirmDictation = useCallback(() => { - const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; - const allowed = socketConnected && (Boolean(agent) || Boolean(onSubmitMessage)); - console.log("[AgentInput] canConfirmDictation", { - allowed, - wsConnected: socketConnected, - }); - return allowed; - }, [agent, onSubmitMessage, wsOrInert]); - - const { - isRecording: isDictating, - isProcessing: isDictationProcessing, - volume: dictationVolume, - duration: dictationDuration, - pendingRequestId: dictationPendingRequestId, - error: dictationError, - status: dictationStatus, - retryAttempt: dictationRetryAttempt, - maxRetryAttempts: dictationMaxRetryAttempts, - retryInfo: dictationRetryInfo, - startDictation, - cancelDictation, - confirmDictation, - retryFailedDictation, - discardFailedDictation, - } = useDictation({ - agentId, - sendAgentAudio, - ws: wsOrInert, - mode: "transcribe_only", - onTranscript: handleDictationTranscript, - onError: handleDictationError, - canStart: canStartDictation, - canConfirm: canConfirmDictation, - enableDuration: true, - }); - - const dictationRequestIdRef = useRef(null); - useEffect(() => { - dictationRequestIdRef.current = dictationPendingRequestId; - }, [dictationPendingRequestId]); - - useEffect(() => { - if (!wsOrInert.subscribeConnectionStatus) { - return; - } - return wsOrInert.subscribeConnectionStatus((status) => { - setConnectionStatus(status); - }); - }, [wsOrInert]); - - const handleRetryFailedRecording = useCallback(() => { - void retryFailedDictation(); - }, [retryFailedDictation]); - - const handleDiscardFailedRecording = useCallback(() => { - discardFailedDictation(); - }, [discardFailedDictation]); - - const dictationToast = useMemo(() => { - console.log('[AgentInputArea] Connection status:', { - isConnected: connectionStatus.isConnected, - wsIsConnected: wsOrInert.isConnected, - wsIsConnecting: wsOrInert.isConnecting, - hasWs: !!ws, - }); - - if (dictationStatus === "retrying") { - const attempt = dictationRetryInfo?.attempt ?? Math.max(1, dictationRetryAttempt || 1); - const maxAttempts = dictationRetryInfo?.maxAttempts ?? dictationMaxRetryAttempts; - const nextLabel = - dictationRetryInfo?.nextRetryMs && dictationRetryInfo.nextRetryMs > 0 - ? ` · Next in ${Math.ceil(dictationRetryInfo.nextRetryMs / 1000)}s` - : ""; - return { - variant: "warning", - title: "Retrying dictation…", - subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Network error", - meta: `Attempt ${attempt}/${maxAttempts}${nextLabel}`, - }; - } - - if (dictationStatus === "failed") { - return { - variant: "error", - title: "Dictation failed", - subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Unknown error", - actionLabel: "Retry", - onAction: handleRetryFailedRecording, - onDismiss: handleDiscardFailedRecording, - }; - } - - return null; - }, [ - dictationError, - dictationMaxRetryAttempts, - dictationRetryAttempt, - dictationRetryInfo, - dictationStatus, - handleDiscardFailedRecording, - handleRetryFailedRecording, - ]); - - useEffect(() => { - const shouldShowOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; - overlayTransition.value = withTiming(shouldShowOverlay ? 1 : 0, { duration: 250 }); - }, [dictationStatus, isDictating, isDictationProcessing, overlayTransition]); - - const debugInputHeight = (label: string, payload: Record) => { - if (!SHOULD_DEBUG_INPUT_HEIGHT) { - return; - } - console.log(`[AgentInput][InputHeight] ${label}`, payload); - }; - useEffect(() => { - inputHeightRef.current = inputHeight; - }, [inputHeight]); useEffect(() => { agentIdRef.current = agentId; @@ -346,304 +144,7 @@ export function AgentInputArea({ onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); - async function handleSendMessage() { - const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; - if (!userInput.trim() || !socketConnected) return; - if (!sendAgentMessage && !onSubmitMessageRef.current) return; - - const message = userInput.trim(); - const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined; - - const isControlled = value !== undefined; - if (!isControlled) { - setUserInput(""); - } - setSelectedImages([]); - setInputHeight(MIN_INPUT_HEIGHT); - setIsProcessing(true); - - try { - await submitMessage(message, imageAttachments); - } catch (error) { - console.error("[AgentInput] Failed to send message:", error); - // Restore input on error so user doesn't lose their message - setUserInput(message); - if (imageAttachments) { - setSelectedImages(imageAttachments); - } - } finally { - setIsProcessing(false); - } - } - - async function handlePickImage() { - const result = await pickImages(); - if (!result?.assets?.length) { - return; - } - - const newImages = result.assets.map(asset => ({ - uri: asset.uri, - mimeType: asset.mimeType || "image/jpeg", - })); - setSelectedImages(prev => [...prev, ...newImages]); - } - - function handleRemoveImage(index: number) { - setSelectedImages(prev => prev.filter((_, i) => i !== index)); - } - - async function handleVoicePress() { - console.log("[AgentInput] handleVoicePress", { - isDictating, - isRealtimeMode, - wsConnected: wsOrInert.isConnected, - }); - if (isDictating || isRealtimeMode) { - return; - } - - if (shouldShowAudioDebug) { - setAudioDebugInfo(null); - } - - try { - await startDictation(); - console.log("[AgentInput] startDictation invoked"); - } catch (error) { - const isCancelled = - error instanceof Error && error.message.includes("Recording cancelled"); - if (!isCancelled) { - console.error("[AgentInput] Failed to start recording:", error); - } - } - } - - async function handleCancelRecording() { - console.log("[AgentInput] handleCancelRecording", { - isDictating, - isDictationProcessing, - }); - if (dictationStatus === "failed") { - handleDiscardFailedRecording(); - return; - } - if (!isDictating && !isDictationProcessing) { - return; - } - try { - await cancelDictation(); - } catch (error) { - console.error("[AgentInput] Failed to cancel recording:", error); - } - } - - async function handleSendRecording() { - console.log("[AgentInput] handleSendRecording", { - isDictating, - isDictationProcessing, - }); - if (dictationStatus === "failed") { - handleRetryFailedRecording(); - return; - } - if (!isDictating) { - return; - } - try { - await confirmDictation(); - } catch (error) { - console.error("[AgentInput] Failed to send recording:", error); - } - } - - useEffect(() => { - if (!shouldShowAudioDebug) { - return; - } - const unsubscribe = wsOrInert.on("transcription_result", (message: SessionOutboundMessage) => { - if (message.type !== "transcription_result") { - return; - } - - const pendingRequestId = dictationRequestIdRef.current; - if (!pendingRequestId || message.payload.requestId !== pendingRequestId) { - return; - } - - dictationRequestIdRef.current = null; - setAudioDebugInfo({ - requestId: pendingRequestId, - transcript: message.payload.text?.trim(), - debugRecordingPath: message.payload.debugRecordingPath ?? undefined, - format: message.payload.format, - byteLength: message.payload.byteLength, - duration: message.payload.duration, - avgLogprob: message.payload.avgLogprob, - isLowConfidence: message.payload.isLowConfidence, - }); - }); - - return () => { - unsubscribe(); - }; - }, [shouldShowAudioDebug, wsOrInert]); - - function isTextAreaLike(value: unknown): value is TextAreaHandle { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as TextAreaHandle; - return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object"; - } - - function getWebTextArea(): TextAreaHandle | null { - if (!IS_WEB) { - return null; - } - - const node = textInputRef.current; - if (!node) { - debugInputHeight("missing-ref", {}); - return null; - } - - if (isTextAreaLike(node)) { - debugInputHeight("using-ref", { - scrollHeight: node.scrollHeight, - inlineHeight: node.style?.height, - }); - return node; - } - - if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") { - const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.(); - if (isTextAreaLike(native)) { - debugInputHeight("using-native-ref", { - scrollHeight: native.scrollHeight, - inlineHeight: native.style?.height, - }); - return native; - } - } - - debugInputHeight("no-textarea-found", {}); - return null; - } - - function focusWebTextInput(): void { - if (!IS_WEB) { - return; - } - const node = textInputRef.current; - if (!node) { - return; - } - - const target: unknown = - typeof (node as { focus?: () => void }).focus === "function" - ? node - : typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function" - ? (node as { getNativeRef?: () => unknown }).getNativeRef?.() - : null; - - if (target && typeof (target as { focus?: () => void }).focus === "function") { - const exec = () => (target as { focus: () => void }).focus(); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(exec); - } else { - setTimeout(exec, 0); - } - } - } - - function measureWebInputHeight(source: string): boolean { - if (!IS_WEB) { - return false; - } - const element = getWebTextArea(); - if (!element?.style || typeof element.scrollHeight !== "number") { - debugInputHeight(`${source}-missing`, {}); - return false; - } - const previousHeight = element.style.height; - element.style.height = "auto"; - const measuredHeight = element.scrollHeight; - element.style.height = previousHeight ?? ""; - const bounded = applyMeasuredHeight(measuredHeight, source, { - scrollHeight: element.scrollHeight, - inlineHeight: previousHeight, - }); - element.style.height = `${bounded}px`; - element.style.minHeight = `${MIN_INPUT_HEIGHT}px`; - element.style.maxHeight = `${MAX_INPUT_HEIGHT}px`; - element.style.overflowY = bounded >= MAX_INPUT_HEIGHT ? "auto" : "hidden"; - return true; - } - - function applyMeasuredHeight( - measuredHeight: number, - source: string, - details: Record = {}, - ): number { - const existingBaseline = baselineInputHeightRef.current; - if (existingBaseline === null) { - baselineInputHeightRef.current = measuredHeight; - } - const baseline = baselineInputHeightRef.current ?? measuredHeight; - const normalizedHeight = measuredHeight - baseline + MIN_INPUT_HEIGHT; - const bounded = Math.min( - MAX_INPUT_HEIGHT, - Math.max(MIN_INPUT_HEIGHT, normalizedHeight), - ); - debugInputHeight(source, { - measuredHeight, - normalizedHeight, - bounded, - baseline, - ...details, - }); - setBoundedInputHeight(bounded); - return bounded; - } - - function setBoundedInputHeight(nextHeight: number) { - const boundedHeight = Math.min( - MAX_INPUT_HEIGHT, - Math.max(MIN_INPUT_HEIGHT, nextHeight), - ); - - if (Math.abs(inputHeightRef.current - boundedHeight) < 1) { - return; - } - - debugInputHeight("set-state", { - nextHeight, - boundedHeight, - previousHeight: inputHeightRef.current, - }); - setInputHeight(boundedHeight); - } - - function handleContentSizeChange( - event: NativeSyntheticEvent, - ) { - if (IS_WEB && measureWebInputHeight("web-measure")) { - return; - } - - applyMeasuredHeight(event.nativeEvent.contentSize.height, "native-measure", { - width: event.nativeEvent.contentSize.width, - }); - } - const isAgentRunning = agent?.status === "running"; - const hasText = userInput.trim().length > 0; - const hasImages = selectedImages.length > 0; - const hasSendableContent = hasText || hasImages; - const shouldShowSendButton = !isAgentRunning && hasSendableContent; - const shouldShowVoiceControls = true; - const shouldHandleDesktopSubmit = IS_WEB; const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { @@ -653,38 +154,89 @@ export function AgentInputArea({ return next; }); }, - [agentId, serverId, setQueuedMessages], + [agentId, serverId, setQueuedMessages] ); - useLayoutEffect(() => { - if (!IS_WEB) { - return; - } - measureWebInputHeight("layout-effect"); - }, [userInput]); - function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleDesktopSubmit) { + function queueMessage(message: string, imageAttachments?: ImageAttachment[]) { + const trimmedMessage = message.trim(); + if (!trimmedMessage && !imageAttachments?.length) return; + + const newItem = { + id: generateMessageId(), + text: trimmedMessage, + images: imageAttachments, + }; + + setQueuedMessages(serverId, (prev: Map) => { + const next = new Map(prev); + next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); + return next; + }); + + const isControlled = value !== undefined; + if (!isControlled) { + setUserInput(""); + } + setSelectedImages([]); + } + + async function sendMessageWithContent( + message: string, + imageAttachments?: ImageAttachment[], + forceSend?: boolean + ) { + const socketConnected = wsOrInert.getConnectionState + ? wsOrInert.getConnectionState().isConnected + : wsOrInert.isConnected; + if (!message.trim() || !socketConnected) return; + if (!sendAgentMessage && !onSubmitMessageRef.current) return; + + const trimmedMessage = message.trim(); + + if (agent?.status === "running" && !forceSend) { + queueMessage(trimmedMessage, imageAttachments); return; } - if (event.nativeEvent.key !== "Enter") { - return; + + const isControlled = value !== undefined; + if (!isControlled) { + setUserInput(""); } - const { metaKey, ctrlKey, shiftKey } = event.nativeEvent; - if (shiftKey || metaKey || ctrlKey) { - return; - } - event.preventDefault(); - if (isAgentRunning) { - if (!hasSendableContent || !wsOrInert.isConnected) { - return; + setSelectedImages([]); + setIsProcessing(true); + + try { + await submitMessage(trimmedMessage, imageAttachments); + } catch (error) { + console.error("[AgentInput] Failed to send message:", error); + setUserInput(trimmedMessage); + if (imageAttachments) { + setSelectedImages(imageAttachments); } - handleQueueCurrentInput(); + } finally { + setIsProcessing(false); + } + } + + function handleSubmit(payload: MessagePayload) { + void sendMessageWithContent(payload.text, payload.images, payload.forceSend); + } + + async function handlePickImage() { + const result = await pickImages(); + if (!result?.assets?.length) { return; } - if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) { - return; - } - void handleSendMessage(); + + const newImages = result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType || "image/jpeg", + })); + setSelectedImages((prev) => [...prev, ...newImages]); + } + + function handleRemoveImage(index: number) { + setSelectedImages((prev) => prev.filter((_, i) => i !== index)); } useEffect(() => { @@ -693,55 +245,6 @@ export function AgentInputArea({ } }, [isAgentRunning, wsOrInert.isConnected]); - useEffect(() => { - if (!IS_WEB) { - return; - } - - const listener = (event: KeyboardEvent) => { - const key = event.key.toLowerCase(); - const isCommandLike = event.metaKey || event.ctrlKey; - - if (isCommandLike && !event.altKey && key === "d") { - event.preventDefault(); - event.stopPropagation(); - - if (isDictating) { - void handleSendRecording(); - return; - } - - if (!isRealtimeMode && shouldShowVoiceControls && wsOrInert.isConnected) { - void handleVoicePress(); - } - return; - } - - if (key === "escape" && isDictating) { - event.preventDefault(); - event.stopPropagation(); - void handleCancelRecording(); - } - }; - - window.addEventListener("keydown", listener, true); - return () => { - window.removeEventListener("keydown", listener, true); - }; - }, [ - isDictating, - isRealtimeMode, - shouldShowVoiceControls, - wsOrInert, - handleVoicePress, - handleSendRecording, - handleCancelRecording, - ]); - - useEffect(() => { - focusWebTextInput(); - }, [agentId]); - // Hydrate draft only when switching agents useEffect(() => { const draft = getDraftInput(agentId); @@ -752,18 +255,21 @@ export function AgentInputArea({ } setUserInput(draft.text); - setSelectedImages(draft.images); + setSelectedImages(draft.images as ImageAttachment[]); }, [agentId, getDraftInput]); // Persist drafts into the shared session store with change detection to avoid redundant work useEffect(() => { const existing = getDraftInput(agentId); const isSameText = existing?.text === userInput; - const existingImages: Array<{ uri: string; mimeType?: string }> = existing?.images ?? []; + const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[]; const isSameImages = existingImages.length === selectedImages.length && - existingImages.every((img: { uri: string; mimeType?: string }, idx: number) => { - return img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType; + existingImages.every((img, idx) => { + return ( + img.uri === selectedImages[idx]?.uri && + img.mimeType === selectedImages[idx]?.mimeType + ); }); if (isSameText && isSameImages) { @@ -773,21 +279,6 @@ export function AgentInputArea({ saveDraftInput(agentId, { text: userInput, images: selectedImages }); }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]); - const overlayAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [{ translateY: (1 - overlayTransition.value) * FOOTER_HEIGHT }], - opacity: overlayTransition.value, - pointerEvents: overlayTransition.value > 0.5 ? ("auto" as const) : ("none" as const), - }; - }); - - const inputAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: 1 - overlayTransition.value, - pointerEvents: overlayTransition.value < 0.5 ? ("auto" as const) : ("none" as const), - }; - }); - const keyboardAnimatedStyle = useAnimatedStyle(() => { "worklet"; const absoluteHeight = Math.abs(keyboardHeight.value); @@ -823,21 +314,6 @@ export function AgentInputArea({ cancelAgentRun(agentIdRef.current); } - function handleQueueCurrentInput() { - if (!hasSendableContent) return; - - const newItem = { - id: generateMessageId(), - text: userInput.trim(), - images: selectedImages.length ? selectedImages : undefined, - }; - - updateQueue((current) => [...current, newItem]); - setUserInput(""); - setSelectedImages([]); - setInputHeight(MIN_INPUT_HEIGHT); - } - function handleEditQueuedMessage(id: string) { const item = queuedMessages.find((q) => q.id === id); if (!item) return; @@ -859,44 +335,66 @@ export function AgentInputArea({ await submitMessage(item.text, item.images); } - const realtimeButton = ( - - {isRealtimeMode ? ( - - ) : ( - + const handleQueue = useCallback((payload: MessagePayload) => { + queueMessage(payload.text, payload.images); + }, []); + + const rightContent = ( + <> + + {isRealtimeMode ? ( + + ) : ( + + )} + + {isAgentRunning && ( + + {isCancellingAgent ? ( + + ) : ( + + )} + )} - + ); - const voiceButton = ( - - {isDictating ? ( - - ) : ( - - )} - + const leftContent = ( + ); return ( - + {/* Border separator */} @@ -914,24 +412,23 @@ export function AgentInputArea({ {/* Input area */} - {/* Regular input controls */} - - {shouldShowAudioDebug && audioDebugInfo ? ( - setAudioDebugInfo(null)} - /> - ) : null} {/* Queue list */} {queuedMessages.length > 0 && ( {queuedMessages.map((item) => ( - + {item.text} - handleEditQueuedMessage(item.id)} style={styles.queueActionButton}> + handleEditQueuedMessage(item.id)} + style={styles.queueActionButton} + > )} - {/* Image preview pills */} - {hasImages && ( - - {selectedImages.map((image, index) => ( - - - handleRemoveImage(index)} style={styles.removeImageButton}> - - - - ))} - - )} - - {/* Full-width text input */} - = MAX_INPUT_HEIGHT} - onContentSizeChange={handleContentSizeChange} - editable={!isDictating && wsOrInert.isConnected} - onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} + autoFocus={autoFocus} + disabled={isRealtimeMode} + leftContent={leftContent} + rightContent={rightContent} + isAgentRunning={isAgentRunning} + onQueue={handleQueue} /> - {/* Button row below input */} - - {/* Left button group */} - - - - - - - - {/* Right button group */} - - {isAgentRunning ? ( - <> - {shouldShowVoiceControls && voiceButton} - {hasSendableContent && ( - - - - )} - {realtimeButton} - - {isCancellingAgent ? ( - - ) : ( - - )} - - - ) : ( - <> - {shouldShowSendButton && ( - - {isSubmitLoading ? ( - - ) : ( - - )} - - )} - {shouldShowVoiceControls && voiceButton} - {realtimeButton} - - )} - - - - - {/* Voice note recording overlay */} - - - - - - {dictationToast ? ( - - - - - - ) : null} ); } @@ -1111,109 +493,9 @@ const styles = StyleSheet.create(((theme: any) => ({ inputAreaContent: { width: "100%", maxWidth: MAX_INPUT_WIDTH, - }, - inputContainer: { - flexDirection: "column", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], gap: theme.spacing[3], - minHeight: FOOTER_HEIGHT, - width: "100%", - }, - overlayContainer: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - alignItems: "center", - paddingBottom: theme.spacing[4], - }, - dictationToastPortal: { - position: "absolute", - left: theme.spacing[4], - right: theme.spacing[4], - bottom: theme.spacing[4], - }, - imagePreviewContainer: { - flexDirection: "row", - gap: theme.spacing[2], - flexWrap: "wrap", - }, - imagePill: { - flexDirection: "row", - alignItems: "center", - backgroundColor: theme.colors.muted, - borderRadius: theme.borderRadius.lg, - padding: theme.spacing[1], - gap: theme.spacing[2], - }, - imageThumbnail: { - width: 40, - height: 40, - borderRadius: theme.borderRadius.md, - }, - removeImageButton: { - width: 24, - height: 24, - alignItems: "center", - justifyContent: "center", - }, - textInput: { - width: "100%", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - backgroundColor: theme.colors.muted, - borderRadius: theme.borderRadius.lg, - color: theme.colors.foreground, - fontSize: theme.fontSize.lg, - lineHeight: theme.fontSize.lg * 1.4, - ...(IS_WEB - ? { - outlineStyle: "none" as const, - outlineWidth: 0, - outlineColor: "transparent", - } - : {}), - }, - buttonRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - leftButtonGroup: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - rightButtonGroup: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - attachButton: { - width: 40, - height: 40, - alignItems: "center", - justifyContent: "center", - }, - sendButton: { - width: 40, - height: 40, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.palette.blue[600], - alignItems: "center", - justifyContent: "center", - }, - voiceButton: { - width: 40, - height: 40, - borderRadius: theme.borderRadius.full, - backgroundColor: "transparent", - alignItems: "center", - justifyContent: "center", - }, - voiceButtonRecording: { - backgroundColor: theme.colors.destructive, }, realtimeButton: { width: 40, @@ -1234,14 +516,6 @@ const styles = StyleSheet.create(((theme: any) => ({ alignItems: "center", justifyContent: "center", }, - queueButton: { - width: 40, - height: 40, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.palette.blue[600], - alignItems: "center", - justifyContent: "center", - }, buttonDisabled: { opacity: 0.5, }, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx new file mode 100644 index 000000000..0e7911c37 --- /dev/null +++ b/packages/app/src/components/message-input.tsx @@ -0,0 +1,568 @@ +import { + View, + TextInput, + Pressable, + NativeSyntheticEvent, + TextInputContentSizeChangeEventData, + TextInputKeyPressEventData, + Image, + Platform, +} from "react-native"; +import { useState, useRef, useCallback, useEffect } from "react"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, +} from "react-native-reanimated"; +import { useDictation } from "@/hooks/use-dictation"; +import { DictationOverlay } from "./dictation-controls"; +import type { UseWebSocketReturn } from "@/hooks/use-websocket"; +import type { SessionContextValue } from "@/contexts/session-context"; + +export interface ImageAttachment { + uri: string; + mimeType: string; +} + +export interface MessagePayload { + text: string; + images?: ImageAttachment[]; + /** When true, bypasses queue and sends immediately even if agent is running */ + forceSend?: boolean; +} + +export interface MessageInputProps { + value: string; + onChangeText: (text: string) => void; + onSubmit: (payload: MessagePayload) => void; + isSubmitDisabled?: boolean; + isSubmitLoading?: boolean; + images?: ImageAttachment[]; + onPickImages?: () => void; + onRemoveImage?: (index: number) => void; + ws: UseWebSocketReturn; + sendAgentAudio: SessionContextValue["sendAgentAudio"]; + placeholder?: string; + autoFocus?: boolean; + disabled?: boolean; + /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ + leftContent?: React.ReactNode; + /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ + rightContent?: React.ReactNode; + /** When true and there's sendable content, calls onQueue instead of onSubmit */ + isAgentRunning?: boolean; + /** Callback for queue button when agent is running */ + onQueue?: (payload: MessagePayload) => void; +} + +const MIN_INPUT_HEIGHT = 50; +const MAX_INPUT_HEIGHT = 160; +const IS_WEB = Platform.OS === "web"; + +type WebTextInputKeyPressEvent = NativeSyntheticEvent< + TextInputKeyPressEventData & { + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + } +>; + +type TextAreaHandle = { + scrollHeight?: number; + style?: { + height?: string; + overflowY?: string; + } & Record; +}; + +export function MessageInput({ + value, + onChangeText, + onSubmit, + isSubmitDisabled = false, + isSubmitLoading = false, + images = [], + onPickImages, + onRemoveImage, + ws, + sendAgentAudio, + placeholder = "Message...", + autoFocus = false, + disabled = false, + leftContent, + rightContent, + isAgentRunning = false, + onQueue, +}: MessageInputProps) { + const { theme } = useUnistyles(); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const textInputRef = useRef unknown }) | null>(null); + const inputHeightRef = useRef(MIN_INPUT_HEIGHT); + const baselineInputHeightRef = useRef(null); + const overlayTransition = useSharedValue(0); + const sendAfterTranscriptRef = useRef(false); + const valueRef = useRef(value); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + // Autofocus on web when autoFocus prop is true + useEffect(() => { + if (!IS_WEB || !autoFocus) return; + // Use requestAnimationFrame to ensure DOM is ready + const rafId = requestAnimationFrame(() => { + textInputRef.current?.focus(); + }); + return () => cancelAnimationFrame(rafId); + }, [autoFocus]); + + const handleDictationTranscript = useCallback( + (text: string, _meta: { requestId: string }) => { + if (!text) return; + const current = valueRef.current; + const shouldPad = current.length > 0 && !/\s$/.test(current); + const nextValue = `${current}${shouldPad ? " " : ""}${text}`; + + const shouldAutoSend = sendAfterTranscriptRef.current; + sendAfterTranscriptRef.current = false; + + if (shouldAutoSend) { + const imageAttachments = images.length > 0 ? images : undefined; + onSubmit({ text: nextValue, images: imageAttachments }); + onChangeText(""); + } else { + onChangeText(nextValue); + } + + if (IS_WEB && typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + measureWebInputHeight("dictation"); + }); + } + }, + [onChangeText, onSubmit, images] + ); + + const handleDictationError = useCallback((error: Error) => { + console.error("[MessageInput] Dictation error:", error); + }, []); + + const canStartDictation = useCallback(() => { + const socketConnected = ws.getConnectionState + ? ws.getConnectionState().isConnected + : ws.isConnected; + return socketConnected && !disabled; + }, [ws, disabled]); + + const canConfirmDictation = useCallback(() => { + const socketConnected = ws.getConnectionState + ? ws.getConnectionState().isConnected + : ws.isConnected; + return socketConnected; + }, [ws]); + + const { + isRecording: isDictating, + isProcessing: isDictationProcessing, + volume: dictationVolume, + duration: dictationDuration, + status: dictationStatus, + startDictation, + cancelDictation, + confirmDictation, + retryFailedDictation, + discardFailedDictation, + } = useDictation({ + sendAgentAudio, + ws, + mode: "transcribe_only", + onTranscript: handleDictationTranscript, + onError: handleDictationError, + canStart: canStartDictation, + canConfirm: canConfirmDictation, + enableDuration: true, + }); + + // Animate overlay + useEffect(() => { + const showOverlay = isDictating || isDictationProcessing || dictationStatus === "retrying" || dictationStatus === "failed"; + overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200 }); + }, [isDictating, isDictationProcessing, dictationStatus, overlayTransition]); + + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: overlayTransition.value, + pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", + })); + + const inputAnimatedStyle = useAnimatedStyle(() => ({ + opacity: 1 - overlayTransition.value, + })); + + const handleVoicePress = useCallback(async () => { + if (isDictating) { + await cancelDictation(); + } else { + await startDictation(); + } + }, [isDictating, cancelDictation, startDictation]); + + const handleCancelRecording = useCallback(async () => { + await cancelDictation(); + }, [cancelDictation]); + + const handleAcceptRecording = useCallback(async () => { + sendAfterTranscriptRef.current = false; + await confirmDictation(); + }, [confirmDictation]); + + const handleAcceptAndSendRecording = useCallback(async () => { + sendAfterTranscriptRef.current = true; + await confirmDictation(); + }, [confirmDictation]); + + const handleRetryFailedRecording = useCallback(() => { + void retryFailedDictation(); + }, [retryFailedDictation]); + + const handleDiscardFailedRecording = useCallback(() => { + discardFailedDictation(); + }, [discardFailedDictation]); + + const handleSendMessage = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed && images.length === 0) return; + const payload = { text: trimmed, images: images.length > 0 ? images : undefined }; + if (isAgentRunning && onQueue) { + onQueue(payload); + } else { + onSubmit(payload); + } + onChangeText(""); + // Reset input height + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + }, [value, images, onSubmit, onChangeText, isAgentRunning, onQueue]); + + // Web input height measurement + function isTextAreaLike(v: unknown): v is TextAreaHandle { + return typeof v === "object" && v !== null && "scrollHeight" in v; + } + + function getWebTextArea(): TextAreaHandle | null { + const ref = textInputRef.current; + if (!ref) return null; + if (typeof (ref as any).getNativeRef === "function") { + const native = (ref as any).getNativeRef(); + if (isTextAreaLike(native)) return native; + } + if (isTextAreaLike(ref)) return ref; + return null; + } + + function measureWebInputHeight(source: string): boolean { + if (!IS_WEB) return false; + const textarea = getWebTextArea(); + if (!textarea || typeof textarea.scrollHeight !== "number") return false; + + const prevHeight = textarea.style?.height; + const prevOverflow = textarea.style?.overflowY; + if (textarea.style) { + textarea.style.height = "auto"; + textarea.style.overflowY = "hidden"; + } + + const scrollHeight = textarea.scrollHeight ?? 0; + if (textarea.style) { + textarea.style.height = prevHeight ?? ""; + textarea.style.overflowY = prevOverflow ?? ""; + } + + if (baselineInputHeightRef.current === null && scrollHeight > 0) { + baselineInputHeightRef.current = scrollHeight; + } + + const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT; + const rawTarget = scrollHeight > 0 ? scrollHeight : baseline; + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget)); + + if (Math.abs(inputHeightRef.current - bounded) >= 1) { + inputHeightRef.current = bounded; + setInputHeight(bounded); + return true; + } + return false; + } + + function setBoundedInputHeight(nextHeight: number) { + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); + if (Math.abs(inputHeightRef.current - bounded) < 1) return; + inputHeightRef.current = bounded; + setInputHeight(bounded); + } + + function handleContentSizeChange( + event: NativeSyntheticEvent + ) { + if (IS_WEB) { + measureWebInputHeight("contentSizeChange"); + return; + } + const contentHeight = event.nativeEvent.contentSize.height; + setBoundedInputHeight(contentHeight); + } + + const shouldHandleDesktopSubmit = IS_WEB; + + function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) { + if (!shouldHandleDesktopSubmit) return; + if (event.nativeEvent.key !== "Enter") return; + const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; + + // Shift+Enter: add newline (default behavior, don't intercept) + if (shiftKey) return; + + // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): force send immediately + if (metaKey || ctrlKey) { + if (isSubmitDisabled || isSubmitLoading || disabled) return; + event.preventDefault(); + const trimmed = value.trim(); + if (!trimmed && images.length === 0) return; + const payload = { + text: trimmed, + images: images.length > 0 ? images : undefined, + forceSend: true, + }; + onSubmit(payload); + onChangeText(""); + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + return; + } + + // Plain Enter: normal send (respects queue behavior) + if (isSubmitDisabled || isSubmitLoading || disabled) return; + event.preventDefault(); + handleSendMessage(); + } + + const hasImages = images.length > 0; + const hasSendableContent = value.trim().length > 0 || hasImages; + const shouldShowSendButton = hasSendableContent; + const isConnected = ws.getConnectionState + ? ws.getConnectionState().isConnected + : ws.isConnected; + + return ( + + {/* Regular input */} + + {/* Image preview pills */} + {hasImages && ( + + {images.map((image, index) => ( + + + {onRemoveImage && ( + onRemoveImage(index)} style={styles.removeImageButton}> + + + )} + + ))} + + )} + + {/* Text input */} + = MAX_INPUT_HEIGHT} + onContentSizeChange={handleContentSizeChange} + editable={!isDictating && isConnected && !disabled} + onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} + autoFocus={autoFocus} + /> + + {/* Button row */} + + {/* Left: attachment button + leftContent slot */} + + {onPickImages && ( + + + + )} + {leftContent} + + + {/* Right: send/queue button, voice button, rightContent slot */} + + {shouldShowSendButton && ( + + + + )} + + {isDictating ? ( + + ) : ( + + )} + + {rightContent} + + + + + {/* Dictation overlay */} + + + + + ); +} + +const styles = StyleSheet.create(((theme: any) => ({ + container: { + position: "relative", + }, + inputWrapper: { + flexDirection: "column", + gap: theme.spacing[3], + }, + imagePreviewContainer: { + flexDirection: "row", + gap: theme.spacing[2], + flexWrap: "wrap", + }, + imagePill: { + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.muted, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[1], + gap: theme.spacing[2], + }, + imageThumbnail: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.md, + }, + removeImageButton: { + width: 24, + height: 24, + alignItems: "center", + justifyContent: "center", + }, + textInput: { + width: "100%", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + backgroundColor: theme.colors.muted, + borderRadius: theme.borderRadius.lg, + color: theme.colors.foreground, + fontSize: theme.fontSize.lg, + lineHeight: theme.fontSize.lg * 1.4, + ...(IS_WEB + ? { + outlineStyle: "none" as const, + outlineWidth: 0, + outlineColor: "transparent", + } + : {}), + }, + buttonRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + leftButtonGroup: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + rightButtonGroup: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + attachButton: { + width: 40, + height: 40, + alignItems: "center", + justifyContent: "center", + }, + voiceButton: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.full, + backgroundColor: "transparent", + alignItems: "center", + justifyContent: "center", + }, + voiceButtonRecording: { + backgroundColor: theme.colors.destructive, + }, + sendButton: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.blue[600], + alignItems: "center", + justifyContent: "center", + }, + buttonDisabled: { + opacity: 0.5, + }, + overlayContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +})) as any) as Record;