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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user