Skip to content

Commit abb74bf

Browse files
dingxiaoboagegr
andauthored
feat(input): Alt/Option+Enter sends follow-up while streaming (#657)
* feat(input): Alt/Option+Enter sends follow-up while streaming * fix(input): guard follow-up shortcut and show key hint --------- Co-authored-by: Alex Yang <agegcn@gmail.com>
1 parent e9f954a commit abb74bf

2 files changed

Lines changed: 79 additions & 3 deletions

File tree

‎components/ChatInput.test.mjs‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
23
import test from "node:test";
4+
import { Script } from "node:vm";
35
import { createJiti } from "jiti";
6+
import ts from "typescript";
47

58
const jiti = createJiti(import.meta.url, {
69
jsx: { runtime: "automatic" },
@@ -13,6 +16,79 @@ const { ModelSelector } = await jiti.import("./ModelSelector.tsx");
1316
const { clearDraft, getDraft, mergeRestoredSubmissionDraft, mergeRestoredSubmissionText, rekeyDraft, setDraft } = await jiti.import("@/lib/draft-store.ts");
1417
const { I18nProvider } = await jiti.import("@/hooks/useI18n");
1518

19+
test("follow-up shortcuts preserve newline, IME, mobile and completion behavior", () => {
20+
const source = ts.createSourceFile("ChatInput.tsx", readFileSync(new URL("./ChatInput.tsx", import.meta.url), "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
21+
function findHandler(node) {
22+
if (ts.isVariableDeclaration(node) && node.name.getText(source) === "handleKeyDown") {
23+
return node.initializer.arguments[0];
24+
}
25+
return ts.forEachChild(node, findHandler);
26+
}
27+
// Execute the component's actual callback without mounting the rest of the UI.
28+
const script = new Script(ts.transpileModule(findHandler(source).getText(source), {
29+
compilerOptions: { target: ts.ScriptTarget.ES2020 },
30+
}).outputText);
31+
const cases = [
32+
["Enter steers", {}, {}, "steer"],
33+
["Alt+Enter follows up", { altKey: true }, {}, "followup"],
34+
["idle Alt+Enter sends", { altKey: true }, { isStreaming: false }, "send"],
35+
["Shift+Enter inserts a newline", { shiftKey: true }, {}, "native"],
36+
["Alt+Shift+Enter keeps native behavior", { altKey: true, shiftKey: true }, {}, "native"],
37+
["composition ref blocks sending", { altKey: true }, { isComposingRef: { current: true } }, "native"],
38+
["native composition blocks sending", { altKey: true, nativeEvent: { isComposing: true } }, {}, "native"],
39+
["IME keyCode blocks sending", { altKey: true, nativeEvent: { keyCode: 229 } }, {}, "native"],
40+
["composition grace blocks sending", { altKey: true }, { lastCompositionEndAtRef: { current: 950 } }, "prevented"],
41+
["mobile Alt+Enter keeps native behavior", { altKey: true }, { isMobile: true }, "native"],
42+
["mobile composition grace cannot send", { altKey: true }, { isMobile: true, lastCompositionEndAtRef: { current: 950 } }, "native"],
43+
["mobile Ctrl+Alt+Enter follows up", { altKey: true, ctrlKey: true }, { isMobile: true }, "followup"],
44+
["mobile Cmd+Alt+Enter follows up", { altKey: true, metaKey: true }, { isMobile: true }, "followup"],
45+
["mobile modified Enter respects composition grace", { altKey: true, ctrlKey: true }, { isMobile: true, lastCompositionEndAtRef: { current: 950 } }, "prevented"],
46+
["Enter falls back to follow-up", {}, { onSteer: undefined }, "followup"],
47+
["Alt+Enter falls back to steer", { altKey: true }, { onFollowUp: undefined }, "steer"],
48+
["slash completion takes priority", { altKey: true }, { slashMenuOpen: true, slashQuery: "help" }, "slash"],
49+
["available built-in commands take priority", { altKey: true }, { slashMenuOpen: true, slashQuery: "copy", value: "/copy", displayedSlashCommands: [{ name: "copy", source: "builtin", availableWhileStreaming: true }] }, "send"],
50+
["file completion takes priority", { altKey: true }, { atMenuOpen: true, atQuery: {} }, "file"],
51+
["history selection takes priority", { altKey: true }, { historyMenuOpen: true }, "history"],
52+
];
53+
for (const [name, keys, state, expected] of cases) {
54+
let action = "native";
55+
const handler = script.runInNewContext({
56+
Date: { now: () => 1000 },
57+
COMPOSITION_END_ENTER_GRACE_MS: 100,
58+
isMobile: false, isStreaming: true,
59+
isComposingRef: { current: false }, lastCompositionEndAtRef: { current: 0 },
60+
historyMenuOpen: false, inputHistory: ["previous"], historyActiveIndex: 0,
61+
slashMenuOpen: false, slashQuery: null, displayedSlashCommands: [{}], slashActiveIndex: 0,
62+
atMenuOpen: false, atQuery: null, atMatches: [{}], atActiveIndex: 0,
63+
onSteer() {}, onFollowUp() {},
64+
sendQueued(mode) { action = mode; }, handleSend() { action = "send"; },
65+
applySlashCommand() { action = "slash"; },
66+
isExactSlashCommand, value: "", setSlashMenuOpen() {},
67+
applyAtCompletion() { action = "file"; },
68+
applyHistoryInput() { action = "history"; },
69+
...state,
70+
});
71+
handler({
72+
key: "Enter", shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
73+
nativeEvent: { isComposing: false, keyCode: 13 },
74+
preventDefault() { action = "prevented"; },
75+
...keys,
76+
});
77+
assert.equal(action, expected, name);
78+
}
79+
});
80+
81+
test("shows the follow-up shortcut in the button tooltip", () => {
82+
const html = renderToStaticMarkup(
83+
React.createElement(I18nProvider, null, React.createElement(ChatInput, {
84+
onSend() {}, onAbort() {}, onFollowUp() {}, isStreaming: true,
85+
})),
86+
);
87+
88+
assert.match(html, /title="Queue this message after the agent finishes \(Alt\/Option\+Enter\)"/);
89+
assert.match(html, /aria-keyshortcuts="Alt\+Enter"/);
90+
});
91+
1692
test("renders the upstream model error", () => {
1793
const html = renderToStaticMarkup(
1894
React.createElement(

‎components/ChatInput.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,8 +1260,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
12601260
if (sendShortcut) {
12611261
e.preventDefault();
12621262
if (isStreaming && (onSteer || onFollowUp)) {
1263-
// Default Enter sends as steer if available, else followup
1264-
sendQueued(onSteer ? "steer" : "followup");
1263+
sendQueued((e.altKey && onFollowUp) || !onSteer ? "followup" : "steer");
12651264
} else {
12661265
handleSend();
12671266
}
@@ -2055,7 +2054,8 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
20552054
<button
20562055
onClick={() => sendQueued("followup")}
20572056
disabled={!canQueueStreamingMessage}
2058-
title={t("chat.followUpHint")}
2057+
title={`${t("chat.followUpHint")} (${isMobile ? "Ctrl/Cmd+" : ""}Alt/Option+Enter)`}
2058+
aria-keyshortcuts={isMobile ? "Control+Alt+Enter Meta+Alt+Enter" : "Alt+Enter"}
20592059
style={{
20602060
display: "flex", alignItems: "center", gap: 5,
20612061
padding: "7px 12px",

0 commit comments

Comments
 (0)