Files
paseo/packages/app/src/components/message-input.tsx
2026-02-04 10:21:36 +07:00

798 lines
23 KiB
TypeScript

import {
View,
TextInput,
Pressable,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
Image,
Platform,
BackHandler,
} from "react-native";
import {
useState,
useRef,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} 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 { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useVoiceOptional } from "@/contexts/voice-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;
client: DaemonClient | null;
placeholder?: string;
autoFocus?: boolean;
disabled?: boolean;
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
isScreenFocused?: 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;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
}
export interface MessageInputRef {
focus: () => void;
blur: () => void;
}
const MIN_INPUT_HEIGHT = 30;
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<string, unknown>;
};
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(
{
value,
onChangeText,
onSubmit,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
onRemoveImage,
client,
placeholder = "Message...",
autoFocus = false,
disabled = false,
isScreenFocused = true,
leftContent,
rightContent,
isAgentRunning = false,
onQueue,
onKeyPress: onKeyPressCallback,
},
ref
) {
const { theme } = useUnistyles();
const voice = useVoiceOptional();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>(null);
const isInputFocusedRef = useRef(false);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
blur: () => {
textInputRef.current?.blur?.();
},
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(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 });
} 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 = client?.isConnected ?? false;
return socketConnected && !disabled;
}, [client, disabled]);
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false;
return socketConnected;
}, [client]);
const {
isRecording: isDictating,
isProcessing: isDictationProcessing,
partialTranscript: dictationPartialTranscript,
volume: dictationVolume,
duration: dictationDuration,
error: dictationError,
status: dictationStatus,
startDictation,
cancelDictation,
confirmDictation,
retryFailedDictation,
discardFailedDictation,
} = useDictation({
client,
onTranscript: handleDictationTranscript,
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isScreenFocused },
enableDuration: true,
});
const isDictatingRef = useRef(isDictating);
useEffect(() => {
isDictatingRef.current = isDictating;
}, [isDictating]);
useEffect(() => {
if (isDictating || isDictationProcessing) {
return;
}
sendAfterTranscriptRef.current = false;
}, [dictationStatus, isDictating, isDictationProcessing]);
// Cmd+D to start/submit dictation, Escape to cancel
useEffect(() => {
if (!IS_WEB) return;
const resolveNativeInput = (): unknown => {
const current = textInputRef.current as any;
if (!current) return null;
if (typeof current.getNativeRef === "function") {
return current.getNativeRef();
}
return current;
};
function handleKeyDown(event: KeyboardEvent) {
const nativeInput = resolveNativeInput();
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
return;
}
const dictating = isDictatingRef.current;
// Cmd+D: start dictation or submit if already dictating
if (
(event.metaKey || event.ctrlKey) &&
(event.code === "KeyD" || event.key.toLowerCase() === "d")
) {
event.preventDefault();
if (dictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
}
return;
}
// Escape: cancel dictation
if (event.key === "Escape" && dictating) {
event.preventDefault();
cancelDictation();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [cancelDictation, confirmDictation, isScreenFocused, startDictation]);
// Animate overlay
useEffect(() => {
const showOverlay =
isDictating ||
isDictationProcessing ||
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 {
if (voice?.isVoiceMode) {
await voice.stopVoice();
}
await startDictation();
}
}, [isDictating, cancelDictation, startDictation, voice]);
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);
onChangeText("");
} else {
onSubmit(payload);
}
// 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<TextInputContentSizeChangeEventData>
) {
if (IS_WEB) {
measureWebInputHeight("contentSizeChange");
return;
}
const contentHeight = event.nativeEvent.contentSize.height;
setBoundedInputHeight(contentHeight);
}
const shouldHandleDesktopSubmit = IS_WEB;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleDesktopSubmit) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
key: event.nativeEvent.key,
preventDefault: () => event.preventDefault(),
});
if (handled) return;
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
const key = event.nativeEvent.key.toLowerCase();
// Cmd+B or Ctrl+B: toggle sidebar
if ((metaKey || ctrlKey) && key === "b") {
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
if ((metaKey || ctrlKey) && key === "d") {
event.preventDefault();
if (isDictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
startDictation();
}
return;
}
// Escape: cancel dictation
if (event.nativeEvent.key === "Escape" && isDictating) {
event.preventDefault();
cancelDictation();
return;
}
if (event.nativeEvent.key !== "Enter") return;
// 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);
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 = client?.isConnected ?? false;
return (
<View style={styles.container}>
{/* Regular input */}
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer}>
{images.map((image, index) => (
<Pressable
key={`${image.uri}-${index}`}
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<Image
source={{ uri: image.uri }}
style={styles.imageThumbnail}
/>
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
]}
>
<X size={16} color="white" />
</View>
)}
</>
)}
</Pressable>
))}
</View>
)}
{/* Text input */}
<TextInput
ref={textInputRef}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
isInputFocusedRef.current = true;
}}
onBlur={() => {
isInputFocusedRef.current = false;
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && isConnected && !disabled}
onKeyPress={
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
}
autoFocus={IS_WEB && autoFocus}
/>
{/* Button row */}
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
style={[
styles.attachButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
)}
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
<Pressable
onPress={handleVoicePress}
disabled={!isConnected || disabled}
style={[
styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
{shouldShowSendButton && (
<Pressable
onPress={handleSendMessage}
disabled={
!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
style={[
styles.sendButton,
(!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
)}
{!shouldShowSendButton && rightContent}
</View>
</View>
</Animated.View>
{/* Dictation overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<DictationOverlay
volume={dictationVolume}
duration={dictationDuration}
transcript={dictationPartialTranscript}
isRecording={isDictating}
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? dictationError ?? undefined : undefined}
onCancel={handleCancelRecording}
onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={
dictationStatus === "failed"
? handleRetryFailedRecording
: undefined
}
onDiscard={
dictationStatus === "failed"
? handleDiscardFailedRecording
: undefined
}
/>
</Animated.View>
</View>
);
}
);
const styles = StyleSheet.create(((theme: any) => ({
container: {
position: "relative",
},
inputWrapper: {
flexDirection: "column",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[2],
md: theme.spacing[4],
},
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease-in-out",
}
: {}),
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePill: {
position: "relative",
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(IS_WEB
? {
cursor: "pointer",
}
: {}),
},
imageThumbnail: {
width: 48,
height: 48,
},
removeImageButton: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
},
textInput: {
width: "100%",
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.normal,
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: 20,
height: 20,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
overlayContainer: {
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
top: 0,
left: 0,
width: "100%",
height: "100%",
right: 0,
bottom: 0,
},
})) as any) as Record<string, any>;