1308 lines
40 KiB
TypeScript
1308 lines
40 KiB
TypeScript
import {
|
|
View,
|
|
TextInput,
|
|
Pressable,
|
|
NativeSyntheticEvent,
|
|
TextInputContentSizeChangeEventData,
|
|
TextInputKeyPressEventData,
|
|
Image,
|
|
Platform,
|
|
Text,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from "react";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, 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 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 }>;
|
|
};
|
|
|
|
interface AgentInputAreaProps {
|
|
agentId: string;
|
|
serverId: string;
|
|
onSubmitMessage?: (payload: {
|
|
text: string;
|
|
images?: Array<{ uri: string; mimeType: string }>;
|
|
}) => Promise<void>;
|
|
/** Externally controlled loading state. When true, disables the submit button. */
|
|
isSubmitLoading?: boolean;
|
|
value?: string;
|
|
onChangeText?: (text: string) => void;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
};
|
|
|
|
type DictationToastConfig = {
|
|
variant: DictationToastVariant;
|
|
title: string;
|
|
subtitle?: string;
|
|
meta?: string;
|
|
actionLabel?: string;
|
|
onAction?: () => void;
|
|
onDismiss?: () => void;
|
|
};
|
|
|
|
export function AgentInputArea({
|
|
agentId,
|
|
serverId,
|
|
onSubmitMessage,
|
|
isSubmitLoading = false,
|
|
value,
|
|
onChangeText,
|
|
}: AgentInputAreaProps) {
|
|
const { theme } = useUnistyles();
|
|
const insets = useSafeAreaInsets();
|
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
|
|
|
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
|
|
|
const agent = useSessionStore((state) =>
|
|
state.sessions[serverId]?.agents?.get(agentId)
|
|
);
|
|
|
|
const getDraftInput = useSessionStore((state) => state.getDraftInput);
|
|
const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
|
|
|
|
const queuedMessagesRaw = useSessionStore((state) =>
|
|
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
|
);
|
|
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
|
|
|
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
|
const sendAgentMessage = methods?.sendAgentMessage;
|
|
const cancelAgentRun = methods?.cancelAgentRun;
|
|
|
|
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
|
|
|
// Noop fallback for sendAgentAudio (required by useDictation)
|
|
const noopSendAgentAudio = useCallback(async () => {}, []);
|
|
const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio;
|
|
|
|
// Inert WebSocket fallback for when ws is undefined
|
|
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
|
() => ({
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
conversationId: null,
|
|
lastError: null,
|
|
send: () => {},
|
|
on: () => () => {},
|
|
sendPing: () => {},
|
|
sendUserMessage: () => {},
|
|
clearAgentAttention: () => {},
|
|
subscribeConnectionStatus: () => () => {},
|
|
getConnectionState: () => ({ isConnected: false, isConnecting: false }),
|
|
}),
|
|
[]
|
|
);
|
|
|
|
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<Array<{ uri: string; mimeType: string }>>([]);
|
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
|
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
|
|
const [connectionStatus, setConnectionStatus] = useState(() =>
|
|
wsOrInert.getConnectionState ? wsOrInert.getConnectionState() : { isConnected: wsOrInert.isConnected, isConnecting: wsOrInert.isConnecting }
|
|
);
|
|
|
|
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
|
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
|
const baselineInputHeightRef = useRef<number | null>(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 agentStatusRef = useRef<string | undefined>(undefined);
|
|
const updateQueueRef = useRef<
|
|
((updater: (current: QueuedMessage[]) => QueuedMessage[]) => void) | null
|
|
>(null);
|
|
const submitMessage = useCallback(
|
|
async (text: string, images?: Array<{ uri: string; mimeType: string }>) => {
|
|
if (onSubmitMessageRef.current) {
|
|
await onSubmitMessageRef.current({ text, images });
|
|
return;
|
|
}
|
|
await sendAgentMessageRef.current?.(agentIdRef.current, text, images);
|
|
},
|
|
[]
|
|
);
|
|
const handleDictationTranscript = useCallback(
|
|
(text: string) => {
|
|
if (!text) {
|
|
return;
|
|
}
|
|
const shouldQueue = agentStatusRef.current === "running";
|
|
if (shouldQueue) {
|
|
updateQueueRef.current?.((current) => [
|
|
...current,
|
|
{
|
|
id: generateMessageId(),
|
|
text,
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
await submitMessage(text);
|
|
} catch (error) {
|
|
console.error("[AgentInput] Failed to send transcribed message:", error);
|
|
updateQueueRef.current?.((current) => [
|
|
...current,
|
|
{
|
|
id: generateMessageId(),
|
|
text,
|
|
},
|
|
]);
|
|
}
|
|
})();
|
|
},
|
|
[submitMessage]
|
|
);
|
|
|
|
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);
|
|
console.log("[AgentInput] canStartDictation", {
|
|
allowed,
|
|
isRealtimeMode,
|
|
wsConnected: socketConnected,
|
|
});
|
|
return allowed;
|
|
}, [agent, isRealtimeMode, wsOrInert]);
|
|
|
|
const canConfirmDictation = useCallback(() => {
|
|
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
|
const allowed = socketConnected && Boolean(agent);
|
|
console.log("[AgentInput] canConfirmDictation", {
|
|
allowed,
|
|
wsConnected: socketConnected,
|
|
});
|
|
return allowed;
|
|
}, [agent, 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<string | null>(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<DictationToastConfig | null>(() => {
|
|
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<string, unknown>) => {
|
|
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
|
|
return;
|
|
}
|
|
console.log(`[AgentInput][InputHeight] ${label}`, payload);
|
|
};
|
|
useEffect(() => {
|
|
inputHeightRef.current = inputHeight;
|
|
}, [inputHeight]);
|
|
|
|
useEffect(() => {
|
|
agentIdRef.current = agentId;
|
|
}, [agentId]);
|
|
|
|
useEffect(() => {
|
|
sendAgentMessageRef.current = sendAgentMessage;
|
|
}, [sendAgentMessage]);
|
|
|
|
useEffect(() => {
|
|
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<string, unknown> = {},
|
|
): 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<TextInputContentSizeChangeEventData>,
|
|
) {
|
|
if (IS_WEB && measureWebInputHeight("web-measure")) {
|
|
return;
|
|
}
|
|
|
|
applyMeasuredHeight(event.nativeEvent.contentSize.height, "native-measure", {
|
|
width: event.nativeEvent.contentSize.width,
|
|
});
|
|
}
|
|
|
|
const isAgentRunning = agent?.status === "running";
|
|
agentStatusRef.current = agent?.status;
|
|
const hasText = userInput.trim().length > 0;
|
|
const hasImages = selectedImages.length > 0;
|
|
const hasSendableContent = hasText || hasImages;
|
|
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
|
const shouldShowVoiceControls = !hasSendableContent;
|
|
const shouldHandleDesktopSubmit = IS_WEB;
|
|
|
|
const updateQueue = useCallback(
|
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, updater(prev.get(agentId) ?? []));
|
|
return next;
|
|
});
|
|
},
|
|
[agentId, serverId, setQueuedMessages],
|
|
);
|
|
useEffect(() => {
|
|
updateQueueRef.current = updateQueue;
|
|
}, [updateQueue]);
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
if (!IS_WEB) {
|
|
return;
|
|
}
|
|
measureWebInputHeight("layout-effect");
|
|
}, [userInput]);
|
|
|
|
function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) {
|
|
if (!shouldHandleDesktopSubmit) {
|
|
return;
|
|
}
|
|
if (event.nativeEvent.key !== "Enter") {
|
|
return;
|
|
}
|
|
const { metaKey, ctrlKey, shiftKey } = event.nativeEvent;
|
|
if (shiftKey || metaKey || ctrlKey) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (isAgentRunning) {
|
|
if (!hasSendableContent || !wsOrInert.isConnected) {
|
|
return;
|
|
}
|
|
handleQueueCurrentInput();
|
|
return;
|
|
}
|
|
if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) {
|
|
return;
|
|
}
|
|
void handleSendMessage();
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isAgentRunning || !wsOrInert.isConnected) {
|
|
setIsCancellingAgent(false);
|
|
}
|
|
}, [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);
|
|
if (!draft) {
|
|
setUserInput("");
|
|
setSelectedImages([]);
|
|
return;
|
|
}
|
|
|
|
setUserInput(draft.text);
|
|
setSelectedImages(draft.images);
|
|
}, [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 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;
|
|
});
|
|
|
|
if (isSameText && isSameImages) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
const shift = Math.max(0, absoluteHeight - insets.bottom);
|
|
return {
|
|
transform: [{ translateY: -shift }],
|
|
};
|
|
});
|
|
|
|
async function handleRealtimePress() {
|
|
try {
|
|
if (isRealtimeMode) {
|
|
await stopRealtime();
|
|
} else {
|
|
if (!wsOrInert.isConnected || !serverId) {
|
|
return;
|
|
}
|
|
await startRealtime(serverId);
|
|
}
|
|
} catch (error) {
|
|
console.error("[AgentInput] Failed to toggle realtime mode:", error);
|
|
}
|
|
}
|
|
|
|
function handleCancelAgent() {
|
|
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
|
return;
|
|
}
|
|
if (!wsOrInert.isConnected || !cancelAgentRun) {
|
|
return;
|
|
}
|
|
setIsCancellingAgent(true);
|
|
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;
|
|
|
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
|
setUserInput(item.text);
|
|
setSelectedImages(item.images ?? []);
|
|
}
|
|
|
|
async function handleSendQueuedNow(id: string) {
|
|
const item = queuedMessages.find((q) => q.id === id);
|
|
if (!item || !wsOrInert.isConnected) return;
|
|
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
|
|
|
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
|
|
|
// Cancels current agent run before sending queued prompt
|
|
handleCancelAgent();
|
|
await submitMessage(item.text, item.images);
|
|
}
|
|
|
|
const realtimeButton = (
|
|
<Pressable
|
|
onPress={handleRealtimePress}
|
|
disabled={!wsOrInert.isConnected && !isRealtimeMode}
|
|
style={[
|
|
styles.realtimeButton as any,
|
|
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
|
(!wsOrInert.isConnected && !isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
|
]}
|
|
>
|
|
{isRealtimeMode ? (
|
|
<Square size={18} color={theme.colors.background} fill={theme.colors.background} />
|
|
) : (
|
|
<AudioLines size={20} color={theme.colors.background} />
|
|
)}
|
|
</Pressable>
|
|
);
|
|
|
|
const voiceButton = (
|
|
<Pressable
|
|
onPress={handleVoicePress}
|
|
disabled={!wsOrInert.isConnected || isRealtimeMode}
|
|
style={[
|
|
styles.voiceButton as any,
|
|
(!wsOrInert.isConnected || isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
|
(isDictating ? styles.voiceButtonRecording : undefined) as any,
|
|
]}
|
|
>
|
|
{isDictating ? (
|
|
<Square size={14} color="white" fill="white" />
|
|
) : (
|
|
<Mic size={20} color={theme.colors.foreground} />
|
|
)}
|
|
</Pressable>
|
|
);
|
|
|
|
return (
|
|
<Animated.View style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}>
|
|
{/* Border separator */}
|
|
<View style={styles.borderSeparator} />
|
|
|
|
{/* Realtime controls - only when active */}
|
|
{isRealtimeMode && (
|
|
<Animated.View
|
|
style={styles.realtimeControlsContainer}
|
|
entering={REALTIME_FADE_IN}
|
|
exiting={REALTIME_FADE_OUT}
|
|
>
|
|
<RealtimeControls />
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* Input area */}
|
|
<View style={styles.inputAreaContainer}>
|
|
<View style={styles.inputAreaContent}>
|
|
{/* Regular input controls */}
|
|
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
|
{shouldShowAudioDebug && audioDebugInfo ? (
|
|
<AudioDebugNotice
|
|
info={audioDebugInfo}
|
|
onDismiss={() => setAudioDebugInfo(null)}
|
|
/>
|
|
) : null}
|
|
{/* Queue list */}
|
|
{queuedMessages.length > 0 && (
|
|
<View style={styles.queueContainer}>
|
|
{queuedMessages.map((item) => (
|
|
<View key={item.id} style={styles.queueItem}>
|
|
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
|
|
{item.text}
|
|
</Text>
|
|
<View style={styles.queueActions}>
|
|
<Pressable onPress={() => handleEditQueuedMessage(item.id)} style={styles.queueActionButton}>
|
|
<Pencil size={14} color={theme.colors.foreground} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => handleSendQueuedNow(item.id)}
|
|
style={[styles.queueActionButton, styles.queueSendButton]}
|
|
>
|
|
<ArrowUp size={14} color={theme.colors.background} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{/* Image preview pills */}
|
|
{hasImages && (
|
|
<View style={styles.imagePreviewContainer}>
|
|
{selectedImages.map((image, index) => (
|
|
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
|
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
|
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
|
|
<X size={16} color={theme.colors.foreground} />
|
|
</Pressable>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{/* Full-width text input */}
|
|
<TextInput
|
|
ref={textInputRef}
|
|
value={userInput}
|
|
onChangeText={setUserInput}
|
|
placeholder="Message agent..."
|
|
placeholderTextColor={theme.colors.mutedForeground}
|
|
style={[
|
|
styles.textInput,
|
|
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
|
]}
|
|
multiline
|
|
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
|
onContentSizeChange={handleContentSizeChange}
|
|
editable={!isDictating && wsOrInert.isConnected}
|
|
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
|
/>
|
|
|
|
{/* Button row below input */}
|
|
<View style={styles.buttonRow}>
|
|
{/* Left button group */}
|
|
<View style={styles.leftButtonGroup}>
|
|
<Pressable
|
|
onPress={handlePickImage}
|
|
disabled={!wsOrInert.isConnected}
|
|
style={[
|
|
styles.attachButton as any,
|
|
(!wsOrInert.isConnected ? styles.buttonDisabled : undefined) as any,
|
|
]}
|
|
>
|
|
<Paperclip size={20} color={theme.colors.foreground} />
|
|
</Pressable>
|
|
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
|
</View>
|
|
|
|
{/* Right button group */}
|
|
<View style={styles.rightButtonGroup}>
|
|
{isAgentRunning ? (
|
|
<>
|
|
{shouldShowVoiceControls && voiceButton}
|
|
{hasSendableContent && (
|
|
<Pressable
|
|
onPress={handleQueueCurrentInput}
|
|
disabled={!wsOrInert.isConnected}
|
|
accessibilityLabel="Queue message while agent is running"
|
|
accessibilityRole="button"
|
|
style={[
|
|
styles.queueButton as any,
|
|
(!wsOrInert.isConnected ? styles.buttonDisabled : undefined) as any,
|
|
]}
|
|
>
|
|
<ArrowUp size={20} color="white" />
|
|
</Pressable>
|
|
)}
|
|
{realtimeButton}
|
|
<Pressable
|
|
onPress={handleCancelAgent}
|
|
disabled={!wsOrInert.isConnected || isCancellingAgent}
|
|
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
|
accessibilityRole="button"
|
|
style={[
|
|
styles.cancelButton as any,
|
|
(!wsOrInert.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
|
]}
|
|
>
|
|
{isCancellingAgent ? (
|
|
<ActivityIndicator size="small" color="white" />
|
|
) : (
|
|
<Square size={18} color="white" fill="white" />
|
|
)}
|
|
</Pressable>
|
|
</>
|
|
) : shouldShowSendButton ? (
|
|
<Pressable
|
|
onPress={handleSendMessage}
|
|
disabled={!wsOrInert.isConnected || isProcessing || isSubmitLoading}
|
|
style={[
|
|
styles.sendButton as any,
|
|
(!wsOrInert.isConnected || isProcessing || isSubmitLoading ? styles.buttonDisabled : undefined) as any,
|
|
]}
|
|
>
|
|
{isSubmitLoading ? (
|
|
<ActivityIndicator size="small" color="white" />
|
|
) : (
|
|
<ArrowUp size={20} color="white" />
|
|
)}
|
|
</Pressable>
|
|
) : shouldShowVoiceControls ? (
|
|
<>
|
|
{voiceButton}
|
|
{realtimeButton}
|
|
</>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
|
|
{/* Voice note recording overlay */}
|
|
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
|
|
<View style={styles.inputAreaContent}>
|
|
<VoiceNoteRecordingOverlay
|
|
volume={dictationVolume}
|
|
duration={dictationDuration}
|
|
onCancel={handleCancelRecording}
|
|
onSend={handleSendRecording}
|
|
isTranscribing={isDictationProcessing}
|
|
status={dictationStatus}
|
|
onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined}
|
|
onDiscardFailed={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
{dictationToast ? (
|
|
<View style={styles.dictationToastPortal} pointerEvents="box-none">
|
|
<View pointerEvents="auto">
|
|
<DictationStatusNotice {...dictationToast} />
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create(((theme: any) => ({
|
|
container: {
|
|
flexDirection: "column",
|
|
position: "relative",
|
|
},
|
|
borderSeparator: {
|
|
height: theme.borderWidth[1],
|
|
backgroundColor: theme.colors.border,
|
|
},
|
|
realtimeControlsContainer: {
|
|
height: FOOTER_HEIGHT,
|
|
},
|
|
inputAreaContainer: {
|
|
position: "relative",
|
|
minHeight: FOOTER_HEIGHT,
|
|
alignItems: "center",
|
|
width: "100%",
|
|
overflow: "hidden",
|
|
},
|
|
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,
|
|
height: 40,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: theme.colors.accentForeground,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
realtimeButtonActive: {
|
|
backgroundColor: theme.colors.palette.blue[600],
|
|
},
|
|
cancelButton: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: theme.colors.palette.red[500],
|
|
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,
|
|
},
|
|
queueContainer: {
|
|
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.card,
|
|
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.muted,
|
|
},
|
|
queueSendButton: {
|
|
backgroundColor: theme.colors.palette.blue[600],
|
|
},
|
|
})) as any) as Record<string, any>;
|