Merge branch 'main' of github.com:getpaseo/paseo
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