Files
paseo/packages/app/src/composer/input/input.tsx
T
paseo-bot[bot] 0e40022519 Preserve linked resources when pasting prompts (#8972)
* fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.

* fix(app): keep pasted links undoable while drafting offline

Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads.

* fix(app): preserve copied Markdown with normalized links

Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact.

* fix(app): retain external linked tables when pasting prompts

Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells.

* test(app): name rich clipboard paste arguments

* fix(app): preserve pasted links around rowless tables

The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations.

* test(app): wait for workspace restore before undo teardown

The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 07:04:10 +00:00

2564 lines
83 KiB
TypeScript

import { useKeyboardFocusSurface } from "@/keyboard";
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Platform,
Text,
useWindowDimensions,
NativeSyntheticEvent,
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
type LayoutChangeEvent,
} from "react-native";
import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useImperativeHandle,
useMemo,
forwardRef,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_DISC_GLYPH_SIZES,
TOOLBAR_DISC_SIZE,
toolbarGlyphsFor,
toolbarGlyphTone,
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import {
COMPOSER_PADDING_BOTTOM,
COMPOSER_PADDING_HORIZONTAL,
COMPOSER_CORNER_RADIUS,
COMPOSER_PADDING_TOP,
COMPOSER_TOOLBAR_SHAPES,
composerToolbarOverhang,
resolveComposerLeadingEdge,
} from "@/composer/input/toolbar-rails";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
import { hasFinePointer } from "@/hooks/pointer-capabilities";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import {
collectImageFilesFromClipboardData,
filesToImageAttachments,
} from "@/utils/image-attachments-from-files";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload, TextReplacement } from "@/composer/types";
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
type EditingTextInputHandle as ComposerTextInputHandle,
type EditingTextInputProps,
} from "@/components/ui/text-input";
const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
} from "./labels";
import {
applyDictationTranscript,
computeCanStartDictation,
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
stopRealtimeVoice,
} from "./state";
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
}
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
export interface AttachmentMenuItem {
id: string;
label: string;
onSelect: () => void;
disabled?: boolean;
icon?: React.ReactElement | null;
}
export interface ComposerInputSnapshot {
text: string;
selection: { start: number; end: number };
}
export interface ComposerKeyPressEvent {
key: string;
/** Shift, Alt, Ctrl, or Meta was held. */
hasModifier: boolean;
preventDefault: () => void;
input: ComposerInputSnapshot;
}
export interface MessageInputProps {
editingSession?: ComposerEditingSession;
submissionError?: React.ReactNode;
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the submit button stays visible and can submit even with no content. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
/** Optional testID for the primary submit button. */
submitButtonTestID?: string;
submitIcon?: "arrow" | "return";
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
/** When true, keep the grown input height after submit (text is preserved, not cleared). */
preserveHeightOnSubmit?: boolean;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
placeholder?: string;
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** The realtime voice mode button after the dictation button, or null when the row hides it. */
voiceModeContent?: React.ReactNode;
/** Primary action to render when the agent is active and the composer has no sendable content. */
activeActionContent?: React.ReactNode;
voiceServerId?: string;
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
/** Controls what the default send action (Enter, send button, dictation) does when the agent is
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
* lives only in DEFAULT_CLIENT_SETTINGS. */
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: ComposerKeyPressEvent) => boolean;
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void;
onHeightChange?: (height: number) => void;
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */
attachmentSlot?: React.ReactNode;
/** What this composer is for. See `@/composer/input-mode` for what each mode implies. */
inputMode?: ComposerInputMode;
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean;
/** Command issued when application state must replace native-owned text. */
textReplacement: TextReplacement;
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string;
}
export interface MessageInputRef {
focus: () => void;
blur: () => void;
getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
*/
getNativeElement?: () => HTMLElement | null;
}
const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
}
>;
interface TextAreaHandle {
value?: string;
scrollHeight?: number;
clientHeight?: number;
offsetHeight?: number;
scrollTop?: number;
selectionStart?: number | null;
selectionEnd?: number | null;
style?: {
height?: string;
overflowY?: string;
};
}
function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) {
return toolbarGlyphTone(glyph, hovered) === "foreground"
? iconForegroundMapping
: iconForegroundMutedMapping;
}
function AttachButtonIcon({
hovered,
onAttachButtonRef,
glyph,
}: {
hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined;
glyph: ToolbarGlyph;
}) {
return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<ThemedPlus
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={glyphColorMapping(glyph, hovered)}
/>
</View>
);
}
function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) {
return (
<>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
testID={`message-input-attachment-menu-item-${item.id}`}
disabled={item.disabled}
onSelect={item.onSelect}
leading={item.icon ?? null}
>
{item.label}
</DropdownMenuItem>
))}
</>
);
}
function AttachmentDropdown({
visible,
isConnected,
disabled,
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
addAttachmentLabel,
}: {
visible: boolean;
isConnected: boolean;
disabled: boolean;
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
attachmentMenuItems: AttachmentMenuItem[];
addAttachmentLabel: string;
}) {
const isButtonDisabled = !isConnected || disabled;
if (!visible) return null;
return (
<DropdownMenu compactMode="sheet">
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
disabled={isButtonDisabled}
accessibilityLabel={addAttachmentLabel}
accessibilityRole="button"
testID="message-input-attach-button"
style={attachButtonStyle}
>
{renderAttachButtonIcon}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{addAttachmentLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="top"
align="start"
offset={8}
minWidth={220}
testID="message-input-attachment-menu"
sheetTitle={addAttachmentLabel}
>
<AttachmentMenuList items={attachmentMenuItems} />
</DropdownMenuContent>
</DropdownMenu>
);
}
function VoiceButtonIcon({
hovered,
isDictating,
isMutedRealtime,
glyph,
discGlyphSize,
}: {
hovered: boolean;
isDictating: boolean;
isMutedRealtime: boolean;
glyph: ToolbarGlyph;
discGlyphSize: number;
}) {
if (isDictating) {
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={discGlyphSize} color="white" fill="white" />
</View>
);
}
const colorMapping = glyphColorMapping(glyph, hovered);
const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic;
return <Icon size={glyph.size} strokeWidth={glyph.strokeWidth} uniProps={colorMapping} />;
}
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
function VoiceTooltipBody({
voiceTooltipText,
shortcut,
}: {
voiceTooltipText: string;
shortcut: ShortcutChord | null | undefined;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
{shortcut ? <Shortcut chord={shortcut} /> : null}
</View>
);
}
function SendTooltipBody({
label,
sendKeys,
}: {
label: string;
sendKeys: ShortcutChord | null | undefined;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
</View>
);
}
function SendButtonContent({
isSubmitLoading,
submitIcon,
submitLabel,
glyph,
}: {
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
submitLabel: string | undefined;
glyph: ToolbarGlyph;
}) {
if (isSubmitLoading) {
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
}
if (submitLabel) {
return <Text style={styles.sendButtonLabel}>{submitLabel}</Text>;
}
if (submitIcon === "return") {
return (
<ThemedCornerDownLeft
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={iconAccentForegroundMapping}
/>
);
}
return (
<ThemedArrowUp
size={glyph.size}
strokeWidth={glyph.strokeWidth}
uniProps={iconAccentForegroundMapping}
/>
);
}
interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
sendShortcut: SendShortcut;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
handleAlternateSendAction: () => void;
handleDefaultSendAction: () => void;
}
function handleDesktopKeyPressImpl(
event: WebTextInputKeyPressEvent,
ctx: DesktopKeyPressContext,
): void {
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
if (ctx.onKeyPressCallback) {
const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key,
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
preventDefault: () => event.preventDefault(),
input: ctx.input,
});
if (handled) return;
}
if (event.nativeEvent.key !== "Enter") return;
const action = resolveMessageInputEnterAction({
submitOnEnter: ctx.submitOnEnter,
sendShortcut: ctx.sendShortcut,
shiftKey: shiftKey ?? false,
metaKey: metaKey ?? false,
ctrlKey: ctrlKey ?? false,
isAgentRunning: ctx.isAgentRunning,
onQueue: ctx.onQueue,
});
if (action === null) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (action === "alternate-send") {
ctx.handleAlternateSendAction();
return;
}
ctx.handleDefaultSendAction();
}
function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
if (!current) return null;
const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
}
interface PasteEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
isRealtimeVoiceForCurrentAgent: boolean;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteEffect(args: PasteEffectArgs): void {
const {
textarea,
isConnected,
disabled,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
} = args;
useEffect(() => {
if (!isWeb) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
event.preventDefault();
void filesToImageAttachments(imageFiles)
.then((pastedAttachments) => {
if (disposed || pastedAttachments.length === 0) return;
onAddImages(pastedAttachments);
return;
})
.catch((error) => {
console.error("[MessageInput] Failed to process pasted images:", error);
});
};
textarea.addEventListener("paste", handlePaste);
return () => {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
}
function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
autoFocus: boolean,
autoFocusKey: string | undefined,
): void {
useEffect(() => {
if (!isWeb || !autoFocus) return;
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
const element = getTextInputNativeElement(textInputRef.current);
const active = typeof document !== "undefined" ? document.activeElement : null;
return Boolean(element) && active === element;
},
deferInitialAttempt: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus, autoFocusKey]);
}
function MessageInputAutoFocus({
enabled,
autoFocusKey,
textInputRef,
}: {
enabled: boolean;
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isPaneFocused } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey);
return null;
}
function MessageInputOverlay({
showDictationOverlay,
showRealtimeOverlay,
voice,
dictationVolume,
dictationDuration,
isDictating,
isDictationProcessing,
dictationStatus,
dictationError,
onCancelRecording,
onAcceptRecording,
onAcceptAndSendRecording,
onRetryFailedRecording,
onDiscardFailedRecording,
onRealtimeVoiceStop,
}: {
showDictationOverlay: boolean;
showRealtimeOverlay: boolean;
voice:
| {
isMuted: boolean;
isVoiceSwitching: boolean;
toggleMute: () => void;
}
| null
| undefined;
dictationVolume: number;
dictationDuration: number;
isDictating: boolean;
isDictationProcessing: boolean;
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
dictationError: string | null;
onCancelRecording: () => Promise<void>;
onAcceptRecording: () => Promise<void>;
onAcceptAndSendRecording: () => Promise<void>;
onRetryFailedRecording: () => void;
onDiscardFailedRecording: () => void;
onRealtimeVoiceStop: () => void;
}) {
if (showDictationOverlay) {
return (
<DictationOverlay
volume={dictationVolume}
duration={dictationDuration}
isRecording={isDictating}
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
onCancel={onCancelRecording}
onAccept={onAcceptRecording}
onAcceptAndSend={onAcceptAndSendRecording}
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
/>
);
}
if (showRealtimeOverlay && voice) {
return (
<RealtimeVoiceOverlay
isMuted={voice.isMuted}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={onRealtimeVoiceStop}
/>
);
}
return null;
}
function FocusHint({
visible,
focusInputKeys,
label,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
{label}
</Text>
);
}
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textReplacementKey: string;
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string;
accessibilityLabel: string;
onChangeText: (text: string) => void;
onFocus: () => void;
onBlur: () => void;
editable: boolean;
scrollEnabled: boolean;
autoFocus: boolean;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
fullscreen: boolean;
compact: boolean;
onRenderedLinesChange: (lines: number) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
}
/**
* The composer's content: an editable input, or static text when there is
* nothing to type. Both sit in the same bordered surface, so read-only is a
* state of this composer rather than a second one.
*/
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
const { onChangeText, onRenderedLinesChange } = props;
const [measurementText, setMeasurementText] = useState(props.value);
useEffect(() => {
if (!isWeb) setMeasurementText(props.value);
}, [props.value, props.textReplacementKey]);
const handleTextChange = useCallback(
(text: string) => {
if (!isWeb) setMeasurementText(text);
onChangeText(text);
},
[onChangeText, setMeasurementText],
);
const measureLines = useCallback(
(event: import("react-native").TextLayoutEvent) =>
onRenderedLinesChange(event.nativeEvent.lines.length),
[onRenderedLinesChange],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
<Text style={props.readOnlyTextStyle} testID="composer-readonly-content">
{props.value}
</Text>
</View>
);
}
return (
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
pointerEvents="none"
style={[props.textInputStyle, styles.lineMeasurement]}
onTextLayout={measureLines}
>
{`${measurementText}\u200b`}
</Text>
) : null}
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
initialValue={props.value}
onChangeText={handleTextChange}
placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
style={props.textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
editable={props.editable}
onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange}
onPasteImages={props.onPasteImages}
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
</View>
);
}
function VoiceButtonTooltip({
visible,
onVoicePress,
isDictationStartEnabled,
voiceButtonAccessibilityLabel,
voiceButtonStyle,
renderVoiceButtonIcon,
voiceTooltipText,
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
}: {
visible: boolean;
onVoicePress: () => void;
isDictationStartEnabled: boolean;
voiceButtonAccessibilityLabel: string;
voiceButtonStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
renderVoiceButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
voiceTooltipText: string;
isRealtimeVoiceForCurrentAgent: boolean;
voiceMuteToggleKeys: ShortcutChord | null | undefined;
dictationToggleKeys: ShortcutChord | null | undefined;
}) {
const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys;
if (!visible) return null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onVoicePress}
disabled={!isDictationStartEnabled}
accessibilityRole="button"
accessibilityLabel={voiceButtonAccessibilityLabel}
style={voiceButtonStyle}
>
{renderVoiceButtonIcon}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<VoiceTooltipBody voiceTooltipText={voiceTooltipText} shortcut={shortcut} />
</TooltipContent>
</Tooltip>
);
}
function SendButtonTooltip({
shouldShow,
canPressLoadingButton,
onSubmitLoadingPress,
onDefaultSendAction,
onLongPressSend,
isSendButtonDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
submitLabel,
submitButtonTestID,
glyph,
sendKeys,
sendTooltipLabel,
}: {
shouldShow: boolean;
canPressLoadingButton: boolean;
onSubmitLoadingPress: (() => void) | undefined;
onDefaultSendAction: () => void;
onLongPressSend: () => void;
isSendButtonDisabled: boolean;
submitAccessibilityLabel: string;
submitAccessibilityHint: string | undefined;
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
submitLabel: string | undefined;
submitButtonTestID: string | undefined;
glyph: ToolbarGlyph;
sendKeys: ShortcutChord | null | undefined;
sendTooltipLabel: string;
}) {
if (!shouldShow) return null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
onLongPress={canPressLoadingButton ? undefined : onLongPressSend}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityHint={submitAccessibilityHint}
accessibilityRole="button"
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
glyph={glyph}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
</TooltipContent>
</Tooltip>
);
}
type PrimaryActionKind = "send" | "active" | "none";
function hasSendableComposerContent(input: {
hasText: boolean;
attachments: readonly ComposerAttachment[];
hasExternalContent: boolean;
}): boolean {
return input.hasText || input.attachments.length > 0 || input.hasExternalContent;
}
function resolvePrimaryActionKind(input: {
hasSendableContent: boolean;
allowEmptySubmit: boolean;
isAgentRunning: boolean;
isSubmitLoading: boolean;
}): PrimaryActionKind {
if (input.hasSendableContent || input.allowEmptySubmit) return "send";
if (input.isAgentRunning) return "active";
if (input.isSubmitLoading) return "send";
return "none";
}
function PrimaryAction({
kind,
activeActionContent,
...sendButtonProps
}: {
kind: PrimaryActionKind;
activeActionContent: React.ReactNode;
} & React.ComponentProps<typeof SendButtonTooltip>) {
if (kind === "active") return activeActionContent;
if (kind === "send") return <SendButtonTooltip {...sendButtonProps} />;
return null;
}
interface ToggleRealtimeVoiceContext {
voice:
| {
isVoiceSwitching: boolean;
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
startVoice: (serverId: string, agentId: string) => Promise<unknown>;
}
| null
| undefined;
voiceServerId: string | undefined;
voiceAgentId: string | undefined;
isConnected: boolean;
disabled: boolean;
isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
exitFullscreen: () => void;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) {
return;
}
if (ctx.voice.isVoiceSwitching) return;
if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) {
void ctx.handleStopRealtimeVoice();
return;
}
if (ctx.isAgentRunning) {
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
ctx.exitFullscreen();
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
if (message && message.trim().length > 0) {
ctx.toast.error(message);
}
});
}
interface StartDictationContext {
dictationUnavailableMessage: string | null | undefined;
canStartDictation: () => boolean;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
exitFullscreen: () => void;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
if (ctx.dictationUnavailableMessage) {
ctx.toast.error(ctx.dictationUnavailableMessage);
return;
}
if (!ctx.canStartDictation()) {
return;
}
ctx.exitFullscreen();
await ctx.startDictation();
}
interface VoicePressContext {
isRealtimeVoiceForCurrentAgent: boolean;
voice: { toggleMute: () => void } | null | undefined;
isDictating: boolean;
cancelDictation: () => Promise<void> | void;
startDictationIfAvailable: () => Promise<void>;
}
async function handleVoicePressImpl(ctx: VoicePressContext): Promise<void> {
if (ctx.isRealtimeVoiceForCurrentAgent && ctx.voice) {
ctx.voice.toggleMute();
return;
}
if (ctx.isDictating) {
await ctx.cancelDictation();
return;
}
await ctx.startDictationIfAvailable();
}
interface SendMessageContext {
value: string;
attachments: ComposerAttachment[];
hasExternalContent: boolean;
allowEmptySubmit: boolean;
cwd: string;
isAgentRunning: boolean;
onSubmit: (payload: MessagePayload) => void;
onMinimizeHeight: () => void;
preserveHeightOnSubmit: boolean;
exitFullscreen: () => void;
}
function sendMessageImpl(ctx: SendMessageContext): void {
const trimmed = ctx.value.trim();
if (
!trimmed &&
ctx.attachments.length === 0 &&
!ctx.hasExternalContent &&
!ctx.allowEmptySubmit
) {
return;
}
ctx.exitFullscreen();
ctx.onSubmit({
text: trimmed,
attachments: ctx.attachments,
cwd: ctx.cwd,
forceSend: ctx.isAgentRunning || undefined,
});
// When the host preserves and locks the composer (e.g. new-workspace creation),
// the text stays put — collapsing the height would clip it. Keep it grown.
if (!ctx.preserveHeightOnSubmit) {
ctx.onMinimizeHeight();
}
}
interface QueueMessageContext {
value: string;
attachments: ComposerAttachment[];
cwd: string;
onQueue: ((payload: MessagePayload) => void) | undefined;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
exitFullscreen: () => void;
}
function queueMessageImpl(ctx: QueueMessageContext): void {
if (!ctx.onQueue) return;
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.exitFullscreen();
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.replaceText("");
ctx.onMinimizeHeight();
}
function computeIsRealtimeVoiceForAgent(
voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
voiceServerId: string | undefined,
voiceAgentId: string | undefined,
): boolean {
if (!voice || !voiceServerId || !voiceAgentId) return false;
return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
}
function computeShouldShowDictationOverlay(
isDictating: boolean,
isDictationProcessing: boolean,
dictationStatus: string,
): boolean {
return isDictating || isDictationProcessing || dictationStatus === "failed";
}
function computeIsDictationStartEnabled(
isReadyForDictation: boolean | undefined,
isConnected: boolean,
disabled: boolean,
): boolean {
return (isReadyForDictation ?? isConnected) && !disabled;
}
function resolveMaxInputHeight(windowHeight: number, compact: boolean): number {
if (compact) return DEFAULT_MAX_INPUT_HEIGHT;
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
if (!current) return null;
const candidate = current as { getNativeRef?: () => unknown };
if (typeof candidate.getNativeRef === "function") {
const native = candidate.getNativeRef();
if (isTextAreaLike(native)) return native;
}
if (isTextAreaLike(current)) return current;
return null;
}
function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
): ComposerInputSnapshot {
const textArea = getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
return { text, selection: { start, end } };
}
interface SendButtonStateInput {
disabled: boolean;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
onSubmitLoadingPress: (() => void) | undefined;
defaultSendBehavior: "interrupt" | "steer" | "queue";
isAgentRunning: boolean;
}
interface SendButtonStateOutput {
canPressLoadingButton: boolean;
isSendButtonDisabled: boolean;
defaultActionQueues: boolean;
}
function computeSendButtonState(input: SendButtonStateInput): SendButtonStateOutput {
const canPressLoadingButton =
input.isSubmitLoading && typeof input.onSubmitLoadingPress === "function";
const isSendButtonDisabled =
input.disabled || (!canPressLoadingButton && (input.isSubmitDisabled || input.isSubmitLoading));
const defaultActionQueues = input.defaultSendBehavior === "queue" && input.isAgentRunning;
return { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues };
}
interface ResolvedMessageInputProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
hasExternalContent: boolean;
allowEmptySubmit: boolean;
submitButtonAccessibilityLabel: string | undefined;
submitButtonTestID: string | undefined;
submitIcon: "arrow" | "return";
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
preserveHeightOnSubmit: boolean;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef: ((node: View | null) => void) | undefined;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string | undefined;
autoFocus: boolean;
autoFocusKey: string | undefined;
disabled: boolean;
leftContent: React.ReactNode;
beforeVoiceContent: React.ReactNode;
voiceModeContent: React.ReactNode;
activeActionContent: React.ReactNode;
voiceServerId: string | undefined;
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined;
onFocusChange: ((focused: boolean) => void) | undefined;
onHeightChange: ((height: number) => void) | undefined;
inputWrapperStyle: import("react-native").ViewStyle | undefined;
attachmentSlot: React.ReactNode;
inputMode: ComposerInputMode;
readOnly: boolean;
textReplacement: TextReplacement;
submitLabel: string | undefined;
}
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
return {
value: props.value,
onChangeText: props.onChangeText,
onSubmit: props.onSubmit,
hasExternalContent: props.hasExternalContent ?? false,
allowEmptySubmit: props.allowEmptySubmit ?? false,
submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel,
submitButtonTestID: props.submitButtonTestID,
submitIcon: props.submitIcon ?? "arrow",
isSubmitDisabled: props.isSubmitDisabled ?? false,
isSubmitLoading: props.isSubmitLoading ?? false,
preserveHeightOnSubmit: props.preserveHeightOnSubmit ?? false,
attachments: props.attachments,
cwd: props.cwd,
attachmentMenuItems: props.attachmentMenuItems,
onAttachButtonRef: props.onAttachButtonRef,
onAddImages: props.onAddImages,
onPasteImages: props.onPasteImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder,
autoFocus: props.autoFocus ?? false,
autoFocusKey: props.autoFocusKey,
disabled: props.disabled ?? false,
leftContent: props.leftContent,
beforeVoiceContent: props.beforeVoiceContent,
voiceModeContent: props.voiceModeContent,
activeActionContent: props.activeActionContent,
voiceServerId: props.voiceServerId,
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
sendShortcut: props.sendShortcut,
onQueue: props.onQueue,
onSubmitLoadingPress: props.onSubmitLoadingPress,
onKeyPressCallback: props.onKeyPress,
onSelectionChangeCallback: props.onSelectionChange,
onFocusChange: props.onFocusChange,
onHeightChange: props.onHeightChange,
inputWrapperStyle: props.inputWrapperStyle,
attachmentSlot: props.attachmentSlot,
inputMode: props.inputMode ?? "chat",
readOnly: props.readOnly ?? false,
textReplacement: props.textReplacement,
submitLabel: props.submitLabel,
};
}
function extractErrorMessage(error: unknown): string | null {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return null;
}
/**
* Send that stands in for the trailing disc, in fullscreen or while there is nothing to send, is
* disabled until there is content.
*/
function resolvePresentationSendDisabled(input: {
standsIn: boolean;
disabled: boolean;
hasText: boolean;
attachments: ComposerAttachment[];
hasExternalContent: boolean;
allowEmpty: boolean;
}): boolean {
return (
input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input))
);
}
function shouldShowFullscreenControl(
compact: boolean,
readOnly: boolean,
overlayVisible: boolean,
renderedLines: number,
) {
return compact && !readOnly && !overlayVisible && renderedLines > 2;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(props, ref) {
const {
value,
onChangeText,
onSubmit,
hasExternalContent,
allowEmptySubmit,
submitButtonAccessibilityLabel,
submitButtonTestID,
submitIcon,
isSubmitDisabled,
isSubmitLoading,
preserveHeightOnSubmit,
attachments,
cwd,
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onPasteImages,
client,
isReadyForDictation,
placeholder,
autoFocus,
autoFocusKey,
disabled,
leftContent,
beforeVoiceContent,
voiceModeContent,
activeActionContent,
voiceServerId,
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
onSelectionChangeCallback,
onFocusChange,
onHeightChange,
inputWrapperStyle,
attachmentSlot,
inputMode,
readOnly,
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isPaneFocused } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const editingSession = useComposerEditingSession(inputMode, props.editingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const controlSizing = controlSizingForLayout(isCompact);
const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon;
const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing];
const toast = useToast();
const voice = useVoiceOptional();
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [isInputFocused, setIsInputFocused] = useState(false);
useKeyboardFocusSurface("message-input", isInputFocused && isPaneFocused);
// Web text is DOM-owned between deferred draft publications. The action button only needs
// this boundary, so publish empty/non-empty transitions without rerendering for every key.
const initialHasLiveText = value.trim().length > 0;
const [hasLiveText, setHasLiveText] = useState(initialHasLiveText);
const hasLiveTextRef = useRef(initialHasLiveText);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const focusIntentRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const paneFocusedRef = useRef(isPaneFocused);
paneFocusedRef.current = isPaneFocused;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const webTextareaRef = useRef<HTMLElement | null>(null);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
// One editor stays mounted through every layout mode, so this ref only follows
// the editor's own mount and unmount.
const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => {
textInputRef.current = input;
if (!isWeb) return;
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}, []);
// Only mouse-driven UIs restore editing focus on navigation. Touch users return
// to the conversation; replaying old focus would reopen their software keyboard.
// Read pointer capability when restoring, without making a pointer change itself
// a focus trigger.
useLayoutEffect(() => {
const finePointer = hasFinePointer();
if (!isPaneFocused && isWeb && !finePointer) {
// Hiding a retained tab only blurs its editor after the browser renders.
// A quick return before that frame would otherwise keep touch focus.
textInputRef.current?.blur();
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
textInputRef.current?.focus();
}
}, [isPaneFocused]);
// The compact and desktop screens mount the editor in different trees, so a
// viewport that crosses the breakpoint remounts it. The draft owner brings the
// text back; the session brings the caret and focus intent back. On web the
// owner may still hold an unpublished edit while the new editor mounts, so the
// caret is placed now against the text on screen and again when the owner's
// hydrated text arrives.
const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null);
useLayoutEffect(() => {
const retained = editingSession.takeEditor();
if (retained) {
const text = textInputRef.current?.getText() ?? valueRef.current;
const selection = resolveRetainedSelection(retained.selection, text.length);
retainedSelectionRef.current = retained.selection;
selectionRef.current = selection;
textInputRef.current?.setSelection(selection);
if (retained.focused && hasFinePointer()) {
focusIntentRef.current = true;
if (paneFocusedRef.current) textInputRef.current?.focus();
}
}
return () => {
editingSession.retainEditor({
selection: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
).selection,
focused: isInputFocusedRef.current || focusIntentRef.current,
});
};
}, [editingSession]);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
onRenderedLinesChange: setRenderedLines,
presentationKey: isFullscreen,
});
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]);
const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]);
// The conversation sizes itself against the inline composer; fullscreen covers it.
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
},
[isFullscreen, onHeightChange],
);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText?.(valueRef.current, nextText);
valueRef.current = nextText;
updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
if (nextText === "") {
textInputRef.current?.reset();
} else {
textInputRef.current?.replaceText(nextText, selection);
}
onChangeText(nextText);
},
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
);
useLayoutEffect(
() =>
editingSession.attachEditor({
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
focus: () => textInputRef.current?.focus(),
}),
[editingSession, replaceText],
);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
blur: () => {
textInputRef.current?.blur();
},
getText: () => textInputRef.current?.getText() ?? valueRef.current,
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText,
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
isDictationRecording: isDictationActive,
markTranscriptForSend: () => {
sendAfterTranscriptRef.current = true;
},
confirmDictation,
cancelDictation,
startDictation: startDictationIfAvailable,
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
}),
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
const sendAfterTranscriptRef = useRef(false);
const serverInfo = useSessionStore(
useCallback(
(state) => {
if (!voiceServerId) {
return null;
}
return state.sessions[voiceServerId]?.serverInfo ?? null;
},
[voiceServerId],
),
);
useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacement.key) return;
appliedTextReplacementKeyRef.current = textReplacement.key;
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
valueRef.current = textReplacement.text;
updateLiveTextPresence(textReplacement.text);
const retainedSelection =
textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null;
retainedSelectionRef.current = null;
if (textReplacement.text === "") {
textInputRef.current?.reset();
} else if (retainedSelection) {
const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length);
selectionRef.current = selection;
textInputRef.current?.replaceText(textReplacement.text, selection);
} else {
textInputRef.current?.replaceText(textReplacement.text);
}
}, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]);
useEffect(() => {
return () => {
onFocusChange?.(false);
};
}, [onFocusChange]);
const handleDictationTranscript = useCallback(
(text: string, _meta: { requestId: string }) => {
const autoSend = sendAfterTranscriptRef.current;
sendAfterTranscriptRef.current = false;
applyDictationTranscript(text, {
value: valueRef.current,
defaultSendBehavior,
isAgentRunning,
onQueue,
onSubmit,
replaceText,
attachments,
cwd,
autoSend,
});
},
[replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
});
const canStartDictation = useCallback(
() =>
computeCanStartDictation({
client,
isReadyForDictation,
disabled,
dictationUnavailableMessage,
}),
[client, disabled, dictationUnavailableMessage, isReadyForDictation],
);
const canConfirmDictation = useCallback(() => client?.isConnected ?? false, [client]);
const isConnected = client?.isConnected ?? false;
const isDictationStartEnabled = computeIsDictationStartEnabled(
isReadyForDictation,
isConnected,
disabled,
);
const {
isRecording: isDictating,
isRecordingActive: isDictationActive,
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,
enableDuration: true,
});
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
voice,
voiceServerId,
voiceAgentId,
);
const showDictationOverlay = computeShouldShowDictationOverlay(
isDictating,
isDictationProcessing,
dictationStatus,
);
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
const showOverlay = showDictationOverlay || showRealtimeOverlay;
const surfacePresentation = resolveComposerSurfacePresentation(showOverlay);
useEffect(() => {
if (isDictating || isDictationProcessing) {
return;
}
sendAfterTranscriptRef.current = false;
}, [dictationStatus, isDictating, isDictationProcessing]);
const startDictationIfAvailable = useCallback(
() =>
startDictationIfAvailableImpl({
dictationUnavailableMessage,
canStartDictation,
toast,
startDictation,
exitFullscreen,
}),
[canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen],
);
const handleVoicePress = useCallback(
() =>
handleVoicePressImpl({
isRealtimeVoiceForCurrentAgent,
voice,
isDictating,
cancelDictation,
startDictationIfAvailable,
}),
[
cancelDictation,
isDictating,
isRealtimeVoiceForCurrentAgent,
startDictationIfAvailable,
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 handleStopRealtimeVoice = useCallback(async () => {
try {
await stopRealtimeVoice({
voice,
isRealtimeVoiceForCurrentAgent,
isAgentRunning,
client,
voiceAgentId,
});
} catch (error) {
console.error("[MessageInput] Failed to stop realtime voice", error);
const message = extractErrorMessage(error);
if (message && message.trim().length > 0) {
toast.error(message);
}
}
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
toggleRealtimeVoiceImpl({
voice,
voiceServerId,
voiceAgentId,
isConnected,
disabled,
isAgentRunning,
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
exitFullscreen,
});
}, [
disabled,
handleStopRealtimeVoice,
exitFullscreen,
isAgentRunning,
isConnected,
t,
toast,
voice,
voiceAgentId,
voiceServerId,
]);
const minimizeInputHeight = useCallback(() => {
resetComposerHeight?.();
}, [resetComposerHeight]);
const handleSendMessage = useCallback(() => {
const liveValue = textInputRef.current?.getText() ?? valueRef.current;
if (!preserveHeightOnSubmit) {
updateLiveTextPresence("");
}
sendMessageImpl({
value: liveValue,
attachments,
hasExternalContent,
allowEmptySubmit,
cwd,
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
exitFullscreen,
});
}, [
allowEmptySubmit,
attachments,
cwd,
onSubmit,
isAgentRunning,
hasExternalContent,
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
exitFullscreen,
]);
const handleQueueMessage = useCallback(
() =>
queueMessageImpl({
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
cwd,
onQueue,
replaceText,
onMinimizeHeight: minimizeInputHeight,
exitFullscreen,
}),
[attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen],
);
const handleDefaultSendAction = useCallback(() => {
runDefaultSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
handleSendMessage,
handleQueueMessage,
});
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
const handleAlternateSendAction = useCallback(() => {
runAlternateSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
handleSendMessage,
handleQueueMessage,
});
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const alternateSendAction = resolveAlternateSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
});
const handleLongPressSend = useCallback(() => {
if (alternateSendAction && isNative) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteEffect({
textarea: webTextArea,
isConnected,
disabled,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
});
const handleSelectionChange = useCallback(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
selectionRef.current = { start, end };
onSelectionChangeCallback?.({ start, end });
},
[onSelectionChangeCallback],
);
const shouldHandleWebKeyPress = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
const handleDesktopKeyPress = useCallback(
(event: WebTextInputKeyPressEvent) => {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
});
},
[
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
],
);
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
hasText: hasLiveText,
attachments,
hasExternalContent,
}),
allowEmptySubmit,
isAgentRunning,
isSubmitLoading,
});
const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } =
computeSendButtonState({
disabled,
isSubmitDisabled,
isSubmitLoading,
onSubmitLoadingPress,
defaultSendBehavior,
isAgentRunning,
});
useIosHardwareKeyboardSubmit({
isEnabled: isInputFocused && !isSendButtonDisabled,
onSubmit: handleDefaultSendAction,
});
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel,
canPressLoadingButton,
defaultActionQueues,
defaultSendBehavior,
isAgentRunning,
t,
});
const submitAccessibilityHint = resolveSubmitAccessibilityHint({
canPressLoadingButton,
alternateSendAction,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
isDictating,
t,
});
const voiceTooltipText = resolveVoiceTooltipText({
isRealtimeVoiceForCurrentAgent,
isMuted: Boolean(voice?.isMuted),
t,
});
const sendTooltipLabel = resolveSendTooltipLabel({
submitButtonAccessibilityLabel,
defaultActionQueues,
t,
});
const handleInputChange = useCallback(
(nextValue: string) => {
updateComposerHeightForText?.(valueRef.current, nextValue);
valueRef.current = nextValue;
updateLiveTextPresence(nextValue);
onChangeText(nextValue);
},
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
);
const handleInputFocus = useCallback(() => {
focusIntentRef.current = true;
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
// A blur in the active pane is a dismissal; only hiding the pane preserves intent.
if (paneFocusedRef.current) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
console.error("[MessageInput] Native paste failed:", message);
toast.error(t("composer.errors.pasteImageFailed"));
},
[t, toast],
);
const attachButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.attachButton,
Boolean(hovered || pressed) && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
],
[isConnected, disabled],
);
const voiceButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.voiceButton,
isDictating && styles.voiceButtonRecording,
Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered,
Boolean(pressed) && isDictating && styles.discPressed,
!isDictationStartEnabled && styles.buttonDisabled,
],
[isDictating, isDictationStartEnabled],
);
const handleRealtimeVoiceStop = useCallback(() => {
void handleStopRealtimeVoice();
}, [handleStopRealtimeVoice]);
const inputWrapperCombinedStyle = useMemo(
() => [
styles.inputWrapper,
Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact,
readOnly && styles.inputWrapperReadOnly,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
],
[inputWrapperStyle, isCompact, isFullscreen, readOnly, surfacePresentation.input.opacity],
);
// `withUnistyles` maps this component's `style` into a `.hash > *` child
// rule, which ties on specificity with react-native-web's own
// `.css-textinput-*` class and loses on source order — so a themed
// `fontFamily` here is silently dropped while every other property lands.
// An inline style outranks both classes. See docs/unistyles.md.
const textInputStyle = useMemo(
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
isCompact && !isFullscreen && styles.compactTextInput,
],
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
const readOnlyTextStyle = useMemo(
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
[mode.isMonospace],
);
// Fullscreen always shows Send; without content it is disabled, and looks it.
// With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row
// does not shift when typing starts.
const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null);
const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind;
const isPresentedSendDisabled = resolvePresentationSendDisabled({
standsIn: sendStandsIn,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useCallback(
({ pressed }: { pressed?: boolean }) => [
styles.sendButton,
Boolean(pressed) && styles.discPressed,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
[surfacePresentation.overlay.opacity],
);
const renderAttachButtonIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<AttachButtonIcon
hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef}
glyph={toolbarGlyph}
/>
),
[onAttachButtonRef, toolbarGlyph],
);
const renderVoiceButtonIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<VoiceButtonIcon
hovered={Boolean(hovered)}
isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
glyph={toolbarGlyph}
discGlyphSize={discGlyphSize}
/>
),
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize],
);
const sendButton = useMemo(
() => (
<PrimaryAction
kind={presentedActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
onLongPressSend={handleLongPressSend}
isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
submitAccessibilityHint={submitAccessibilityHint}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
glyph={toolbarGlyph}
sendKeys={resolveSendKeys(sendShortcut)}
sendTooltipLabel={sendTooltipLabel}
/>
),
[
presentedActionKind,
activeActionContent,
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
handleLongPressSend,
isPresentedSendDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
submitLabel,
submitButtonTestID,
toolbarGlyph,
sendShortcut,
sendTooltipLabel,
],
);
const textSurface = useMemo(
() => (
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={
readOnly
? value
: resolveComposerSurfaceText(
valueRef.current,
textReplacement,
appliedTextReplacementKeyRef.current,
)
}
textReplacementKey={textReplacement.key}
textInputRef={assignTextInput}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
fullscreen={isFullscreen}
compact={isCompact}
onRenderedLinesChange={setRenderedLines}
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</RenderProfile>
),
[
readOnly,
textReplacement,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
placeholder,
t,
mode.accessibilityLabelKey,
handleInputChange,
handleInputFocus,
handleInputBlur,
isDictating,
isRealtimeVoiceForCurrentAgent,
disabled,
isComposerScrollEnabled,
shouldHandleWebKeyPress,
handleDesktopKeyPress,
handleSelectionChange,
onPasteImages,
handlePasteError,
isFullscreen,
isCompact,
isInputFocused,
value,
focusInputKeys,
],
);
const leadingEdge = resolveComposerLeadingEdge({
showsAttach: mode.showAttachments,
showsAgentControls: leftContent != null,
});
// The trailing control is always a disc: voice mode, then Send or Stop.
const trailingEdge = "disc";
const buttonRowStyle = useMemo(
() => [
styles.buttonRow,
{
marginLeft: -composerToolbarOverhang(leadingEdge, controlSizing),
marginRight: -composerToolbarOverhang(trailingEdge, controlSizing),
},
],
[controlSizing, leadingEdge, trailingEdge],
);
const toolbar = useMemo(
() => (
<View style={buttonRowStyle}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
visible={mode.showAttachments}
isConnected={isConnected}
disabled={disabled}
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/>
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
visible={mode.showVoice}
onVoicePress={handleVoicePress}
isDictationStartEnabled={isDictationStartEnabled}
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
voiceButtonStyle={voiceButtonStyle}
renderVoiceButtonIcon={renderVoiceButtonIcon}
voiceTooltipText={voiceTooltipText}
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{voiceModeContent}
{sendButton}
</View>
</View>
),
[
buttonRowStyle,
mode.showAttachments,
isConnected,
disabled,
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
t,
leftContent,
beforeVoiceContent,
mode.showVoice,
handleVoicePress,
isDictationStartEnabled,
voiceButtonAccessibilityLabel,
voiceButtonStyle,
renderVoiceButtonIcon,
voiceTooltipText,
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
voiceModeContent,
sendButton,
],
);
const voiceOverlay = useMemo(
() => (
<View
style={overlayContainerStyle}
pointerEvents={surfacePresentation.overlay.pointerEvents}
>
<MessageInputOverlay
showDictationOverlay={showDictationOverlay}
showRealtimeOverlay={showRealtimeOverlay}
voice={voice}
dictationVolume={dictationVolume}
dictationDuration={dictationDuration}
isDictating={isDictating}
isDictationProcessing={isDictationProcessing}
dictationStatus={dictationStatus}
dictationError={dictationError}
onCancelRecording={handleCancelRecording}
onAcceptRecording={handleAcceptRecording}
onAcceptAndSendRecording={handleAcceptAndSendRecording}
onRetryFailedRecording={handleRetryFailedRecording}
onDiscardFailedRecording={handleDiscardFailedRecording}
onRealtimeVoiceStop={handleRealtimeVoiceStop}
/>
</View>
),
[
overlayContainerStyle,
surfacePresentation.overlay.pointerEvents,
showDictationOverlay,
showRealtimeOverlay,
voice,
dictationVolume,
dictationDuration,
isDictating,
isDictationProcessing,
dictationStatus,
dictationError,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
handleRetryFailedRecording,
handleDiscardFailedRecording,
handleRealtimeVoiceStop,
],
);
return (
<View
ref={rootRef}
style={
isFullscreen
? styles.fullscreenContainer
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
controlSizing={controlSizing}
sendIconSize={toolbarGlyph.size}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
error={props.submissionError}
onExit={exitFullscreen}
inputWrapperRef={inputWrapperRef}
inputWrapperStyle={inputWrapperCombinedStyle}
inputPointerEvents={surfacePresentation.input.pointerEvents}
attachmentSlot={attachmentSlot}
showFullscreenButton={shouldShowFullscreenControl(
isCompact,
readOnly,
showOverlay,
renderedLines,
)}
onEnter={enterFullscreen}
overlay={voiceOverlay}
/>
</View>
);
},
);
const styles = StyleSheet.create((theme: Theme) => ({
container: {
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flex: 1,
position: "relative",
},
inputWrapper: {
...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}),
flexShrink: 1,
flexDirection: "column",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: COMPOSER_CORNER_RADIUS,
paddingTop: COMPOSER_PADDING_TOP,
paddingBottom: COMPOSER_PADDING_BOTTOM,
paddingHorizontal: COMPOSER_PADDING_HORIZONTAL,
...(isWeb
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease-in-out",
}
: {}),
},
// Lifts the composer off the chat it sits under on phones.
inputWrapperCompact: {
...theme.shadow.sm,
},
// Dotted says "this surface is the same box, but there is nothing to type
// into it" without swapping the border colour, which reads as an error state.
inputWrapperReadOnly: {
borderStyle: "dotted",
},
// The band the fullscreen glyph sits in is the input's own padding, so a tap
// anywhere in it (under the glyph, or where the glyph would be) is a tap on the
// editor: it focuses, shows the keyboard, and places the caret. Reserved whether
// or not the glyph shows, so text never reflows when it appears.
compactTextInput: { paddingRight: 36 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 0,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
height: undefined,
},
textInputScrollWrapper: {
flexShrink: 1,
position: "relative",
},
focusHintText: {
position: "absolute",
top: 0,
right: 0,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
opacity: 0.5,
},
textInput: {
// Preserve the controls when an ancestor constrains an overlong draft.
flexShrink: 1,
width: "100%",
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
fontWeight: theme.fontWeight.normal,
// No lineHeight on native. React Native applies it as a span over the text, and an
// empty trailing line is laid out from the font's own metrics on some devices, so
// the input jumps when the first character lands on a new line. The font's natural
// line box is the same for every line. Web keeps the CSS value.
...(isWeb
? ({
lineHeight: theme.fontSize.content * 1.4,
outlineStyle: "none",
outlineWidth: 0,
outlineColor: "transparent",
} as object)
: {}),
},
textInputMonospace: {
fontFamily: theme.fontFamily.mono,
},
readOnlyText: {
minHeight: MIN_INPUT_HEIGHT,
color: theme.colors.foregroundMuted,
},
// The row keeps a full touch target's height; its disc-sized controls centre in it.
buttonRow: {
minHeight: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
leftButtonGroup: {
minWidth: 0,
flexShrink: 1,
flexGrow: 1,
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
},
attachButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.destructive,
alignItems: "center",
justifyContent: "center",
},
sendButton: {
minWidth: TOOLBAR_DISC_SIZE,
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
height: TOOLBAR_DISC_SIZE,
alignItems: "center",
justifyContent: "center",
},
sendButtonDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonDiscLabeled: {
minWidth: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
sendButtonLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.accentForeground,
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
discPressed: {
opacity: 0.7,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
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 unknown as Record<string, object>;
const ThemedPlus = withUnistyles(Plus);
const ThemedMic = withUnistyles(Mic);
const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });