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;