Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-08-16 17:08:16 +02:00
15 changed files with 1185 additions and 182 deletions
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { AfterPaintPublication, type AfterPaintScheduler } from "./after-paint-publication";
function createManualScheduler(): AfterPaintScheduler & {
run: () => void;
scheduledCount: () => number;
} {
let callbacks: Array<() => void> = [];
return {
schedule: (callback) => {
callbacks.push(callback);
return () => {
callbacks = callbacks.filter((candidate) => candidate !== callback);
};
},
run: () => {
const scheduled = callbacks;
callbacks = [];
for (const callback of scheduled) callback();
},
scheduledCount: () => callbacks.length,
};
}
describe("AfterPaintPublication", () => {
it("publishes the latest staged value once after paint", () => {
const scheduler = createManualScheduler();
const published: string[] = [];
const publication = new AfterPaintPublication(
(value: string) => published.push(value),
scheduler,
);
publication.stage("a");
publication.stage("ab");
publication.stage("abc");
expect(scheduler.scheduledCount()).toBe(1);
expect(published).toEqual([]);
scheduler.run();
expect(published).toEqual(["abc"]);
});
it("flushes synchronously and cancels the scheduled publication", () => {
const scheduler = createManualScheduler();
const published: string[] = [];
const publication = new AfterPaintPublication(
(value: string) => published.push(value),
scheduler,
);
publication.stage("draft");
publication.flush();
scheduler.run();
expect(published).toEqual(["draft"]);
});
it("cancels pending text before a draft is cleared", () => {
const scheduler = createManualScheduler();
const published: string[] = [];
const publication = new AfterPaintPublication(
(value: string) => published.push(value),
scheduler,
);
publication.stage("sent text");
publication.cancel();
scheduler.run();
expect(published).toEqual([]);
});
});
@@ -0,0 +1,60 @@
export interface AfterPaintScheduler {
schedule: (callback: () => void) => () => void;
}
export const browserAfterPaintScheduler: AfterPaintScheduler = {
schedule: (callback) => {
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) callback();
});
return () => {
cancelled = true;
};
}
let timeoutId: number | null = null;
const frameId = window.requestAnimationFrame(() => {
timeoutId = window.setTimeout(callback, 0);
});
return () => {
window.cancelAnimationFrame(frameId);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
},
};
export class AfterPaintPublication<T> {
private pending: T | null = null;
private cancelScheduled: (() => void) | null = null;
constructor(
private readonly publish: (value: T) => void,
private readonly scheduler: AfterPaintScheduler = browserAfterPaintScheduler,
) {}
stage(value: T): void {
this.pending = value;
if (this.cancelScheduled) return;
this.cancelScheduled = this.scheduler.schedule(() => {
this.cancelScheduled = null;
this.flush();
});
}
flush(): void {
if (this.pending === null) return;
this.cancelScheduled?.();
this.cancelScheduled = null;
const value = this.pending;
this.pending = null;
this.publish(value);
}
cancel(): void {
this.cancelScheduled?.();
this.cancelScheduled = null;
this.pending = null;
}
}
+44 -4
View File
@@ -22,6 +22,8 @@ import {
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { isWeb } from "@/constants/platform";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -110,19 +112,32 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey],
);
const textPublication = useMemo(
() =>
new AfterPaintPublication<string>((nextText) => {
saveDraft((current) => ({ ...current, text: nextText }));
}),
[saveDraft],
);
const editText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
if (isWeb) {
textPublication.stage(nextText);
} else {
saveDraft((current) => ({ ...current, text: nextText }));
}
},
[saveDraft],
[saveDraft, textPublication],
);
const replaceText = useCallback(
(nextText: string) => {
textPublication.cancel();
saveDraft((current) => ({ ...current, text: nextText }));
setTextReplacementRevision((revision) => revision + 1);
},
[saveDraft],
[saveDraft, textPublication],
);
const setAttachments = useCallback(
@@ -137,11 +152,36 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
textPublication.cancel();
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
},
[draftKey],
[draftKey, textPublication],
);
useEffect(() => {
const flushWhenHidden = () => {
if (document.visibilityState === "hidden") textPublication.flush();
};
const flush = () => textPublication.flush();
const canListenForPageHide =
isWeb && typeof window !== "undefined" && typeof window.addEventListener === "function";
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", flushWhenHidden);
}
if (canListenForPageHide) {
window.addEventListener("pagehide", flush);
}
return () => {
if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", flushWhenHidden);
}
if (canListenForPageHide) {
window.removeEventListener("pagehide", flush);
}
textPublication.flush();
};
}, [textPublication]);
useEffect(() => {
let cancelled = false;
void (async () => {
+81 -54
View File
@@ -47,7 +47,12 @@ import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-s
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import {
MessageInput,
type AttachmentMenuItem,
type ComposerKeyPressEvent,
type MessageInputRef,
} from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -76,6 +81,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import {
useHostRuntimeAgentDirectoryStatus,
@@ -98,6 +104,8 @@ import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { isWeb, isNative } from "@/constants/platform";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
@@ -1115,7 +1123,9 @@ function ComposerVoiceModeButton({
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerContent {...props} />
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</ComposerKeyboardScopeProvider>
);
}
@@ -1238,6 +1248,7 @@ function ComposerContentImpl({
onPullRequestAdded: onGithubPrAutoAttach,
});
const [cursorIndex, setCursorIndex] = useState(0);
const cursorPublication = useMemo(() => new AfterPaintPublication<number>(setCursorIndex), []);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
@@ -1322,12 +1333,20 @@ function ComposerContentImpl({
});
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
const selectAutocompleteOption = autocomplete.onSelectOption;
const handleAutocompleteSelect = useCallback(
(option: AutocompleteOption) =>
selectAutocompleteOption(option, messageInputRef.current?.getInputSnapshot()),
[selectAutocompleteOption],
);
// Clear send error when user edits the input
useEffect(() => {
setCursorIndex((current) => Math.min(current, userInput.length));
}, [userInput.length]);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
@@ -1835,8 +1854,7 @@ function ComposerContentImpl({
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) =>
autocompleteOnKeyPressRef.current(event),
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
[],
);
@@ -2084,9 +2102,16 @@ function ComposerContentImpl({
attachButtonRef.current = node;
}, []);
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
setCursorIndex(selection.start);
}, []);
const handleSelectionChange = useCallback(
(selection: { start: number; end: number }) => {
if (isWeb) {
cursorPublication.stage(selection.start);
} else {
setCursorIndex(selection.start);
}
},
[cursorPublication],
);
const handleFocusChange = useCallback(
(focused: boolean) => {
@@ -2236,7 +2261,7 @@ function ComposerContentImpl({
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
onSelect={handleAutocompleteSelect}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
@@ -2244,52 +2269,54 @@ function ComposerContentImpl({
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
</RenderProfile>
<Combobox
options={githubSearchOptions}
value=""
+112 -55
View File
@@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
@@ -87,6 +88,17 @@ export interface AttachmentMenuItem {
icon?: React.ReactElement | null;
}
export interface ComposerInputSnapshot {
text: string;
selection: { start: number; end: number };
}
export interface ComposerKeyPressEvent {
key: string;
preventDefault: () => void;
input: ComposerInputSnapshot;
}
export interface MessageInputProps {
value: string;
onChangeText: (text: string) => void;
@@ -137,7 +149,7 @@ export interface MessageInputProps {
/** 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: { key: string; preventDefault: () => void }) => boolean;
onKeyPress?: (event: ComposerKeyPressEvent) => boolean;
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void;
@@ -160,6 +172,7 @@ export interface MessageInputRef {
focus: () => void;
blur: () => void;
getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
@@ -361,7 +374,8 @@ function SendButtonContent({
}
interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
@@ -382,6 +396,7 @@ function handleDesktopKeyPressImpl(
const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key,
preventDefault: () => event.preventDefault(),
input: ctx.input,
});
if (handled) return;
}
@@ -775,11 +790,11 @@ function SendButtonTooltip({
type PrimaryActionKind = "send" | "active" | "none";
function hasSendableComposerContent(input: {
value: string;
hasText: boolean;
attachments: readonly ComposerAttachment[];
hasExternalContent: boolean;
}): boolean {
return input.value.trim().length > 0 || input.attachments.length > 0 || input.hasExternalContent;
return input.hasText || input.attachments.length > 0 || input.hasExternalContent;
}
function resolvePrimaryActionKind(input: {
@@ -997,6 +1012,18 @@ function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHa
return null;
}
function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
): ComposerInputSnapshot {
const text = current?.getText() ?? fallbackText;
const textArea = getWebTextAreaImpl(current);
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
return { text, selection: { start, end } };
}
interface SendButtonStateInput {
disabled: boolean;
isSubmitDisabled: boolean;
@@ -1055,7 +1082,7 @@ interface ResolvedMessageInputProps {
defaultSendBehavior: "interrupt" | "steer" | "queue";
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined;
onFocusChange: ((focused: boolean) => void) | undefined;
onHeightChange: ((height: number) => void) | undefined;
@@ -1181,20 +1208,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false);
// 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 valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
valueRef.current = nextText;
updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText);
},
[onChangeText],
[onChangeText, updateLiveTextPresence],
);
useImperativeHandle(ref, () => ({
@@ -1205,6 +1247,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textInputRef.current?.blur();
},
getText: () => textInputRef.current?.getText() ?? valueRef.current,
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText,
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
@@ -1240,8 +1284,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value;
updateLiveTextPresence(value);
textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]);
}, [textReplacementKey, updateLiveTextPresence, value]);
useEffect(() => {
return () => {
@@ -1443,30 +1488,33 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [onHeightChange]);
const handleSendMessage = useCallback(
() =>
sendMessageImpl({
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
hasExternalContent,
allowEmptySubmit,
cwd,
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
}),
[
allowEmptySubmit,
const handleSendMessage = useCallback(() => {
const liveValue = textInputRef.current?.getText() ?? valueRef.current;
if (!preserveHeightOnSubmit) {
updateLiveTextPresence("");
}
sendMessageImpl({
value: liveValue,
attachments,
cwd,
onSubmit,
isAgentRunning,
hasExternalContent,
minimizeInputHeight,
allowEmptySubmit,
cwd,
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
],
);
});
}, [
allowEmptySubmit,
attachments,
cwd,
onSubmit,
isAgentRunning,
hasExternalContent,
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
]);
const handleQueueMessage = useCallback(
() =>
@@ -1558,6 +1606,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
(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],
@@ -1570,6 +1619,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
@@ -1583,7 +1637,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
value,
hasText: hasLiveText,
attachments,
hasExternalContent,
}),
@@ -1635,9 +1689,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback(
(nextValue: string) => {
valueRef.current = nextValue;
updateLiveTextPresence(nextValue);
onChangeText(nextValue);
},
[onChangeText],
[onChangeText, updateLiveTextPresence],
);
const handleInputFocus = useCallback(() => {
@@ -1762,31 +1817,33 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
>
{attachmentSlot}
{/* Text input */}
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
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={isWeb ? inputHeight >= maxInputHeight : true}
autoFocus={false}
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
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={isWeb ? inputHeight >= maxInputHeight : true}
autoFocus={false}
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</RenderProfile>
{/* Button row */}
<View style={styles.buttonRow}>
@@ -1,6 +1,6 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { ComposerTextInput } from "./text-input.web";
interface MountedInput {
@@ -73,12 +73,31 @@ afterEach(() => {
act(() => mounted.root.unmount());
mounted.container.remove();
}
vi.useRealTimers();
});
describe("ComposerTextInput web IME composition", () => {
it("keeps locally typed text when its parent rerenders with a stale value", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
act(() => {
typeFromIme(mounted.textarea, "locally typed");
mounted.root.render(
<ComposerTextInput
text=""
multiline={true}
onChangeText={recorder.onChangeText}
placeholder="rerender with stale publication"
testID="composer-input"
/>,
);
});
expect(mounted.textarea.value).toBe("locally typed");
expect(recorder.changes).toEqual(["locally typed"]);
});
it("keeps the DOM-owned candidate during composition and reports the committed text", () => {
vi.useFakeTimers();
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
@@ -100,14 +119,12 @@ describe("ComposerTextInput web IME composition", () => {
act(() => {
dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
});
expect(recorder.changes).toEqual(["你好"]);
});
it("does not let a previous composition-end timer take ownership from a new composition", () => {
vi.useFakeTimers();
it("keeps the latest candidate across consecutive compositions", () => {
const mounted = mountInput(ignoreTextChange);
act(() => {
@@ -116,7 +133,6 @@ describe("ComposerTextInput web IME composition", () => {
dispatchComposition(mounted.textarea, "compositionend");
dispatchComposition(mounted.textarea, "compositionstart");
mounted.textarea.value = "你好";
vi.runAllTimers();
mounted.root.render(
<ComposerTextInput
text="你"
@@ -132,7 +148,6 @@ describe("ComposerTextInput web IME composition", () => {
});
it("does not report committed text twice when the input event already reported it", () => {
vi.useFakeTimers();
const changes: string[] = [];
const mounted = mountInput((text) => changes.push(text));
@@ -140,7 +155,6 @@ describe("ComposerTextInput web IME composition", () => {
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好");
dispatchComposition(mounted.textarea, "compositionend");
vi.runAllTimers();
});
expect(changes).toEqual(["你好"]);
@@ -1,11 +1,4 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
@@ -13,11 +6,8 @@ import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-inp
interface WebTextInputElement extends TextInput {
value?: string;
setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void;
removeEventListener: (
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
addEventListener: (type: "compositionend", listener: EventListener) => void;
removeEventListener: (type: "compositionend", listener: EventListener) => void;
}
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
@@ -32,39 +22,22 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text);
const onChangeTextRef = useRef(onChangeText);
const [isComposing, setIsComposing] = useState(false);
if (!isComposing) textRef.current = text;
onChangeTextRef.current = onChangeText;
useEffect(() => {
const input = inputRef.current as WebTextInputElement | null;
if (!input) return;
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
const startComposition = () => {
if (compositionEndTimer !== null) {
clearTimeout(compositionEndTimer);
compositionEndTimer = null;
}
setIsComposing(true);
};
const endComposition = () => {
const nextText = input.value ?? "";
if (nextText !== textRef.current) {
textRef.current = nextText;
onChangeTextRef.current(nextText);
}
compositionEndTimer = setTimeout(() => {
compositionEndTimer = null;
setIsComposing(false);
}, 0);
};
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition);
return () => {
if (compositionEndTimer !== null) clearTimeout(compositionEndTimer);
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition);
};
}, []);
@@ -99,7 +72,7 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
<ThemedTextInput
{...props}
ref={inputRef}
value={isComposing ? undefined : text}
defaultValue={text}
onChangeText={handleChangeText}
/>
);