perf(app): keep composer typing within the frame budget (#3450)

* perf(app): keep composer typing responsive

The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character.

* fix(app): preserve live composer state at actions

* fix(app): use live input for autocomplete clicks

* fix(app): keep submit activity atomic
This commit is contained in:
Mohamed Boudra
2026-08-16 16:05:29 +02:00
committed by GitHub
parent 42245d139a
commit 8c4e54eace
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}
/>
);
@@ -38,6 +38,17 @@ interface UseAgentAutocompleteInput {
canExecuteClientSlashCommand?: boolean;
}
interface AgentAutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
input: AgentAutocompleteInputSnapshot;
}
interface AgentAutocompleteInputSnapshot {
text: string;
selection: { start: number; end: number };
}
type AgentAutocompleteOption =
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
| (AutocompleteOption & { type: "provider_command" })
@@ -55,8 +66,38 @@ interface AgentAutocompleteResult {
errorMessage?: string;
loadingText: string;
emptyText: string;
onSelectOption: (option: AutocompleteOption) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean;
}
interface AgentAutocompleteSnapshot {
text: string;
slashCommand: SlashCommandRange | null;
fileMention: FileMentionRange | null;
}
function resolveAgentAutocompleteSnapshot(input: {
input?: AgentAutocompleteInputSnapshot;
userInput: string;
cursorIndex: number;
activeSlashCommand: SlashCommandRange | null;
activeFileMention: FileMentionRange | null;
}): AgentAutocompleteSnapshot {
if (!input.input) {
return {
text: input.userInput,
slashCommand: input.activeSlashCommand,
fileMention: input.activeFileMention,
};
}
const text = input.input.text;
const cursorIndex = input.input.selection.start;
return {
text,
slashCommand: findActiveSlashCommand({ text, cursorIndex }),
fileMention: findActiveFileMention({ text, cursorIndex }),
};
}
interface DirectorySuggestionEntry {
@@ -426,8 +467,18 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
);
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
(option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
const selected = option as AgentAutocompleteOption;
const current = resolveAgentAutocompleteSnapshot({
input: snapshot,
userInput,
cursorIndex,
activeSlashCommand,
activeFileMention,
});
const selectedIsCommand =
selected.type === "client_command" || selected.type === "provider_command";
if (snapshot && selectedIsCommand && !current.slashCommand) return;
if (
selected.type === "client_command" &&
selected.command.execution === "immediate" &&
@@ -438,16 +489,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return;
}
if (selected.type === "client_command" || selected.type === "provider_command") {
if (!activeSlashCommand) {
if (selectedIsCommand) {
if (!current.slashCommand) {
setUserInput(`/${selected.id} `);
onAutocompleteApplied?.();
return;
}
const nextInput = applySlashCommandReplacement({
text: userInput,
command: activeSlashCommand,
text: current.text,
command: current.slashCommand,
commandName: selected.id,
});
setUserInput(nextInput);
@@ -455,9 +506,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return;
}
if (!current.fileMention) return;
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
text: current.text,
mention: current.fileMention,
relativePath: selected.entryPath,
});
setUserInput(nextInput);
@@ -469,15 +521,23 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
onClientSlashCommand,
setUserInput,
userInput,
cursorIndex,
activeFileMention,
activeSlashCommand,
],
);
const selectOptionFromKeyPress = useCallback(
(option: AutocompleteOption, event?: AgentAutocompleteKeyPressEvent) =>
onSelectOption(option, event?.input),
[onSelectOption],
);
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onSelectOption: selectOptionFromKeyPress,
onEscape:
mode === "command" && activeSlashCommand?.position === "start"
? () => setUserInput("")
+18 -9
View File
@@ -5,23 +5,32 @@ import {
type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> {
interface AutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
}
interface UseAutocompleteInput<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
> {
isVisible: boolean;
options: readonly TOption[];
query: string;
onSelectOption: (option: TOption) => void;
onSelectOption: (option: TOption, event?: TKeyPressEvent) => void;
onEscape?: () => void;
optionsPosition?: AutocompleteOptionsPosition;
}
interface UseAutocompleteResult {
interface UseAutocompleteResult<TKeyPressEvent extends AutocompleteKeyPressEvent> {
selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
onKeyPress: (event: TKeyPressEvent) => boolean;
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>,
): UseAutocompleteResult {
export function useAutocomplete<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
@@ -56,7 +65,7 @@ export function useAutocomplete<TOption>(
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
const onKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
(event: TKeyPressEvent) => {
if (!input.isVisible || input.options.length === 0) {
return false;
}
@@ -97,7 +106,7 @@ export function useAutocomplete<TOption>(
: fallbackIndex;
const selectedOption = input.options[resolvedIndex];
if (selectedOption) {
input.onSelectOption(selectedOption);
input.onSelectOption(selectedOption, event);
}
return true;
}
@@ -56,7 +56,6 @@ import {
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
@@ -744,7 +743,7 @@ function normalizeBranchDetails(
interface SubmitDraftInput {
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
@@ -855,7 +854,7 @@ interface CreateChatAgentInput {
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
supportsForgeSearch: boolean;
labels: {
@@ -919,7 +918,7 @@ function buildComposerInitialValues(input: {
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const { text, attachments, cwd } = payload;
if (!composerState) {
throw new Error(input.labels.composerStateRequired);
@@ -949,7 +948,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
});
submitWorkspaceDraft({
serverId,
draftKey,
clearDraft,
draftId: input.draftId,
initialSetup,
workspaceId: ensuredWorkspace.id,
@@ -1026,7 +1025,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
draftKey,
clearDraft,
draftId: draftIdInput,
workspaceId,
workspaceDirectory,
@@ -1078,12 +1077,12 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
});
clearDraft("sent");
navigateToWorkspace({
serverId,
workspaceId,
target: submission.target,
});
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
}
function useNewWorkspaceHostSelector(input: {
@@ -2071,7 +2070,7 @@ export function NewWorkspaceScreen({
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
draftKey,
clearDraft: chatDraft.clear,
draftId,
supportsForgeSearch,
labels: {
@@ -2089,7 +2088,7 @@ export function NewWorkspaceScreen({
[
composerState,
draftId,
draftKey,
chatDraft.clear,
ensureWorkspace,
forkDraftSetup,
launchTarget,