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

* perf(app): keep composer typing responsive

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

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

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

* fix(app): keep submit activity atomic
This commit is contained in:
Mohamed Boudra
2026-08-16 16:05:29 +02:00
committed by GitHub
parent 42245d139a
commit 8c4e54eace
15 changed files with 1185 additions and 182 deletions
@@ -38,6 +38,17 @@ interface UseAgentAutocompleteInput {
canExecuteClientSlashCommand?: boolean;
}
interface AgentAutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
input: AgentAutocompleteInputSnapshot;
}
interface AgentAutocompleteInputSnapshot {
text: string;
selection: { start: number; end: number };
}
type AgentAutocompleteOption =
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
| (AutocompleteOption & { type: "provider_command" })
@@ -55,8 +66,38 @@ interface AgentAutocompleteResult {
errorMessage?: string;
loadingText: string;
emptyText: string;
onSelectOption: (option: AutocompleteOption) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean;
}
interface AgentAutocompleteSnapshot {
text: string;
slashCommand: SlashCommandRange | null;
fileMention: FileMentionRange | null;
}
function resolveAgentAutocompleteSnapshot(input: {
input?: AgentAutocompleteInputSnapshot;
userInput: string;
cursorIndex: number;
activeSlashCommand: SlashCommandRange | null;
activeFileMention: FileMentionRange | null;
}): AgentAutocompleteSnapshot {
if (!input.input) {
return {
text: input.userInput,
slashCommand: input.activeSlashCommand,
fileMention: input.activeFileMention,
};
}
const text = input.input.text;
const cursorIndex = input.input.selection.start;
return {
text,
slashCommand: findActiveSlashCommand({ text, cursorIndex }),
fileMention: findActiveFileMention({ text, cursorIndex }),
};
}
interface DirectorySuggestionEntry {
@@ -426,8 +467,18 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
);
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
(option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
const selected = option as AgentAutocompleteOption;
const current = resolveAgentAutocompleteSnapshot({
input: snapshot,
userInput,
cursorIndex,
activeSlashCommand,
activeFileMention,
});
const selectedIsCommand =
selected.type === "client_command" || selected.type === "provider_command";
if (snapshot && selectedIsCommand && !current.slashCommand) return;
if (
selected.type === "client_command" &&
selected.command.execution === "immediate" &&
@@ -438,16 +489,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return;
}
if (selected.type === "client_command" || selected.type === "provider_command") {
if (!activeSlashCommand) {
if (selectedIsCommand) {
if (!current.slashCommand) {
setUserInput(`/${selected.id} `);
onAutocompleteApplied?.();
return;
}
const nextInput = applySlashCommandReplacement({
text: userInput,
command: activeSlashCommand,
text: current.text,
command: current.slashCommand,
commandName: selected.id,
});
setUserInput(nextInput);
@@ -455,9 +506,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return;
}
if (!current.fileMention) return;
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
text: current.text,
mention: current.fileMention,
relativePath: selected.entryPath,
});
setUserInput(nextInput);
@@ -469,15 +521,23 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
onClientSlashCommand,
setUserInput,
userInput,
cursorIndex,
activeFileMention,
activeSlashCommand,
],
);
const selectOptionFromKeyPress = useCallback(
(option: AutocompleteOption, event?: AgentAutocompleteKeyPressEvent) =>
onSelectOption(option, event?.input),
[onSelectOption],
);
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onSelectOption: selectOptionFromKeyPress,
onEscape:
mode === "command" && activeSlashCommand?.position === "start"
? () => setUserInput("")
+18 -9
View File
@@ -5,23 +5,32 @@ import {
type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> {
interface AutocompleteKeyPressEvent {
key: string;
preventDefault: () => void;
}
interface UseAutocompleteInput<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
> {
isVisible: boolean;
options: readonly TOption[];
query: string;
onSelectOption: (option: TOption) => void;
onSelectOption: (option: TOption, event?: TKeyPressEvent) => void;
onEscape?: () => void;
optionsPosition?: AutocompleteOptionsPosition;
}
interface UseAutocompleteResult {
interface UseAutocompleteResult<TKeyPressEvent extends AutocompleteKeyPressEvent> {
selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
onKeyPress: (event: TKeyPressEvent) => boolean;
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>,
): UseAutocompleteResult {
export function useAutocomplete<
TOption,
TKeyPressEvent extends AutocompleteKeyPressEvent = AutocompleteKeyPressEvent,
>(input: UseAutocompleteInput<TOption, TKeyPressEvent>): UseAutocompleteResult<TKeyPressEvent> {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
@@ -56,7 +65,7 @@ export function useAutocomplete<TOption>(
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
const onKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
(event: TKeyPressEvent) => {
if (!input.isVisible || input.options.length === 0) {
return false;
}
@@ -97,7 +106,7 @@ export function useAutocomplete<TOption>(
: fallbackIndex;
const selectedOption = input.options[resolvedIndex];
if (selectedOption) {
input.onSelectOption(selectedOption);
input.onSelectOption(selectedOption, event);
}
return true;
}