From ff3d994c45467a5495a85dcb2c529a286a932cd0 Mon Sep 17 00:00:00 2001 From: killersteps <34531389+killersteps@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:22:43 +0800 Subject: [PATCH] fix: wrap keyboard selection in the @ file picker ArrowDown at the last match and ArrowUp at the first match currently clamp, so the list cannot be cycled. Wrap the active index so the highlighted row returns to the other end, matching typical picker UX. --- components/ChatInput.test.mjs | 62 ++++++++++++++++++++++++++++++++++- components/ChatInput.tsx | 9 +++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 8eeabee8a..972089f3c 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -11,7 +11,7 @@ const jiti = createJiti(import.meta.url, { }); const React = await jiti.import("react"); const { renderToStaticMarkup } = await jiti.import("react-dom/server"); -const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, canClearBuiltinCommandInput, canRestoreUserMessage, canRunBuiltinSlashCommandWhileStreaming, compressImageFile, filterModelOptions, getUpwardMenuMaxHeight, getUserMessageText, getUserMessageDraftImages, isExactSlashCommand, modelSupportsImageInput, replaceLinksWithMarkdown, shouldCompressImageFile } = await jiti.import("./ChatInput.tsx"); +const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, canClearBuiltinCommandInput, canRestoreUserMessage, canRunBuiltinSlashCommandWhileStreaming, compressImageFile, cycleListIndex, filterModelOptions, getUpwardMenuMaxHeight, getUserMessageText, getUserMessageDraftImages, isExactSlashCommand, modelSupportsImageInput, replaceLinksWithMarkdown, shouldCompressImageFile } = await jiti.import("./ChatInput.tsx"); const { ModelSelector } = await jiti.import("./ModelSelector.tsx"); const { clearDraft, getDraft, mergeRestoredSubmissionDraft, mergeRestoredSubmissionText, rekeyDraft, setDraft } = await jiti.import("@/lib/draft-store.ts"); const { I18nProvider } = await jiti.import("@/hooks/useI18n"); @@ -99,6 +99,66 @@ test("follow-up shortcuts preserve newline, IME, mobile and completion behavior" } }); +test("file mention arrows wrap around the match list", () => { + const source = ts.createSourceFile("ChatInput.tsx", readFileSync(new URL("./ChatInput.tsx", import.meta.url), "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + function findHandler(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === "handleKeyDown") { + return node.initializer.arguments[0]; + } + return ts.forEachChild(node, findHandler); + } + const script = new Script(ts.transpileModule(findHandler(source).getText(source), { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }).outputText); + + function move(key, atActiveIndex, length) { + let next = null; + const handler = script.runInNewContext({ + Date: { now: () => 1000 }, + COMPOSITION_END_ENTER_GRACE_MS: 100, + isMobile: false, isStreaming: false, + isComposingRef: { current: false }, lastCompositionEndAtRef: { current: 0 }, + historyMenuOpen: false, inputHistory: [], historyActiveIndex: 0, + slashMenuOpen: false, slashQuery: null, displayedSlashCommands: [], slashActiveIndex: 0, + atMenuOpen: true, atQuery: {}, atMatches: Array.from({ length }, () => ({})), atActiveIndex, + onSteer() {}, onFollowUp() {}, + sendQueued() {}, handleSend() {}, + applySlashCommand() {}, + isExactSlashCommand() { return false; }, value: "@file", + setSlashMenuOpen() {}, setAtMenuOpen() {}, + applyAtCompletion() {}, + applyHistoryInput() {}, + cycleListIndex, + setAtActiveIndex(update) { + next = typeof update === "function" ? update(atActiveIndex) : update; + }, + }); + handler({ + key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, + nativeEvent: { isComposing: false, keyCode: 0 }, + preventDefault() {}, + }); + return next; + } + + assert.equal(move("ArrowDown", 0, 3), 1); + assert.equal(move("ArrowDown", 2, 3), 0); + assert.equal(move("ArrowUp", 0, 3), 2); + assert.equal(move("ArrowUp", 1, 3), 0); + assert.equal(move("ArrowDown", 0, 1), 0); + assert.equal(move("ArrowDown", 0, 0), 0); +}); + +test("cycleListIndex wraps in both directions", () => { + assert.equal(cycleListIndex(0, 3, 1), 1); + assert.equal(cycleListIndex(2, 3, 1), 0); + assert.equal(cycleListIndex(0, 3, -1), 2); + assert.equal(cycleListIndex(1, 3, -1), 0); + assert.equal(cycleListIndex(0, 1, 1), 0); + assert.equal(cycleListIndex(4, 0, 1), 0); + assert.equal(cycleListIndex(-1, 4, 1), 0); +}); + test("shows the follow-up shortcut in the button tooltip", () => { const html = renderToStaticMarkup( React.createElement(I18nProvider, null, React.createElement(ChatInput, { diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 7871b02a5..c4007edfd 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -108,6 +108,11 @@ export function getUpwardMenuMaxHeight(menuBottom: number, visibleTop: number, g return Math.max(0, Math.floor(menuBottom - visibleTop - gap)); } +export function cycleListIndex(index: number, length: number, delta: number): number { + if (length <= 0) return 0; + return ((index + delta) % length + length) % length; +} + export function replaceLinksWithMarkdown( text: string, links: Iterable<{ label: string; href: string; occurrence: number }>, @@ -1269,12 +1274,12 @@ export const ChatInput = forwardRef(function ChatInput({ if (atMenuOpen && atQuery !== null && !isComposing) { if (e.key === "ArrowDown") { e.preventDefault(); - setAtActiveIndex((i) => Math.min(Math.max(0, atMatches.length - 1), i + 1)); + setAtActiveIndex((i) => cycleListIndex(i, atMatches.length, 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); - setAtActiveIndex((i) => Math.max(0, i - 1)); + setAtActiveIndex((i) => cycleListIndex(i, atMatches.length, -1)); return; } if (e.key === "Escape") {