diff --git a/docs/evidence/model-picker-shortcut/after-narrow.png b/docs/evidence/model-picker-shortcut/after-narrow.png new file mode 100644 index 00000000..bee22dfb Binary files /dev/null and b/docs/evidence/model-picker-shortcut/after-narrow.png differ diff --git a/docs/evidence/model-picker-shortcut/after.png b/docs/evidence/model-picker-shortcut/after.png new file mode 100644 index 00000000..dd869f37 Binary files /dev/null and b/docs/evidence/model-picker-shortcut/after.png differ diff --git a/docs/evidence/model-picker-shortcut/before.png b/docs/evidence/model-picker-shortcut/before.png new file mode 100644 index 00000000..047d714e Binary files /dev/null and b/docs/evidence/model-picker-shortcut/before.png differ diff --git a/docs/evidence/model-picker-shortcut/settings-remap.png b/docs/evidence/model-picker-shortcut/settings-remap.png new file mode 100644 index 00000000..b1e813b5 Binary files /dev/null and b/docs/evidence/model-picker-shortcut/settings-remap.png differ diff --git a/docs/evidence/model-picker-shortcut/verification.md b/docs/evidence/model-picker-shortcut/verification.md new file mode 100644 index 00000000..d99c7b61 --- /dev/null +++ b/docs/evidence/model-picker-shortcut/verification.md @@ -0,0 +1,46 @@ +# 模型选择快捷键验证 + +默认 `Ctrl/⌘+Shift+M` 从当前聊天输入框直接打开模型列表。设置的“打开模型选择器 / Open model picker”支持录制、清除和恢复默认。打开列表不选择模型;仍使用原有可用性检查、禁用状态、方向键和 Esc 焦点恢复。 + +基线为 `cace352a4b6515b211b826be598ca8f0fbded440`。前图使用修改前保留的 `.tmp/before-dist`(入口 `index-BqRcF9xq.js`),后图使用本次 `npm run build` 的 `web/dist`(入口 `index-CmAroUUm.js`)。两者由 Vite preview 在同一地址 `127.0.0.1:53872` 提供生产静态文件,API 始终代理到同一个真实 Go 服务 `127.0.0.1:53837`。没有改写 API 响应或页面内容。 + +## 真实浏览器结果 + +2026-10-22,Windows Chrome 155.0.0.0;桌面视口为 1440×960。 + +使用本分支 `cmd/workbenchjourney` 编译的真实 API、临时 SQLite/项目与离线 provider。保存聊天 `thread-run-20261021214544-e651a6ee4baf` 的发送返回 HTTP 202,实际读取 README.md 并保存回复 `Read README.md: JOURNEY_FILE_PROOF_2026`。前后保持同一聊天和草稿 `保留这份草稿:先查看模型,再继续讨论。`。 + +| 操作 | 实际结果 | +| --- | --- | +| 原版纯文本输入按 Ctrl+Shift+M | 没有应用菜单,草稿保持原样。 | +| 新版相同位置按 Ctrl+Shift+M | 直接出现“选择模型路由”,焦点位于原有“返回模型设置”按钮。 | +| 打开、关闭模型列表 | 仅新增 `GET /api/v1/models/routes/available`,返回 HTTP 200;无模型路由写请求。当前 anthropic / journey-model 保持选中,未配置凭据的模型保持禁用。 | +| 设置中录制 Ctrl+Alt+M | 保存 `modelPicker: Mod+Alt+M`;录制和设置页面都没有打开模型菜单。 | +| 返回聊天,使用旧键、新键 | 旧键不再打开;Ctrl+Alt+M 打开列表,草稿仍逐字相同。恢复默认后 Ctrl+Shift+M 再次有效。 | +| Markdown 输入,390×844 | 默认键打开列表;正文保持相同,Esc 关闭列表。菜单 x=35.33、宽=342、右缘=377.33,在视口内。 | + +窄屏外层 document/body 的 clientWidth/scrollWidth 均为 390。此基线的聊天记录区域仍有既有横向滚动条:`.v2-conversation-scroll` 为 390/448,关闭模型列表后仍然存在;用户消息 tooltip 仍使用原 `left: 0` 样式。本次未改动该样式。 + +初次准备数据时,Markdown 模式将 harness 标记 `JOURNEY_READ` 序列化为带转义字符的文本,该次请求返回 412,未作为成功记录。随后以纯文本发送同一 harness 指令取得上述真实 202 和保存回复。截图侧栏保留该次失败聊天。 + +## 截图 + +前后图使用同一 API、保存聊天、草稿及 1440×960 视口。 + +![修改前:按键没有打开模型菜单](before.png) + +![修改后:直接打开真实模型列表](after.png) + +![设置中实际改为 Ctrl+Alt+M](settings-remap.png) + +![390px Markdown 输入打开模型列表](after-narrow.png) + +## 聚焦验证 + +`npm test -- src/v2/components/model-route-control.test.tsx src/v2/components/keyboard-settings.test.tsx`:35 项通过。 + +新增用例覆盖 Ctrl/Meta 默认键、直接列表/只读打开、草稿和焦点、禁用模型、改键/清除/持久化、旧自定义键冲突、无局部处理器时不 preventDefault、权限不足,以及 IME、重复按键、录制、弹窗、其他菜单、Monaco 和终端保护。`npm run build` 通过;`git diff --check` 通过。 + +实际浏览器覆盖 Windows Ctrl、设置改键、纯文本与 Markdown。Meta 只做组件事件验证,未声称 macOS 实机验证。 + +参考:[官方命令页](https://learn.chatgpt.com/docs/reference/commands)列出 Open model picker:Windows/Linux 为 Ctrl+Shift+M,macOS 当前列为 Control+Shift+M。本应用沿用现有 Mod 规则,接受 Ctrl 或 Meta;设置在 macOS 显示 ⌘。 diff --git a/web/package-lock.json b/web/package-lock.json index 9d8c1bd7..03e7da3c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ "@tiptap/starter-kit": "3.32.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "dompurify": "3.4.17", "highlight.js": "11.11.1", "lucide-react": "1.31.0", "monaco-editor": "0.56.0", @@ -42,7 +43,7 @@ "openapi-typescript": "7.13.0", "typescript": "5.9.3", "vite": "8.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } }, "node_modules/@adobe/css-tools": { @@ -1388,16 +1389,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1406,13 +1407,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1433,9 +1434,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1446,13 +1447,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1460,14 +1461,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1476,9 +1477,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1486,13 +1487,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1840,9 +1841,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4281,9 +4282,9 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -4498,19 +4499,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4538,12 +4539,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/web/package.json b/web/package.json index 89d9de27..2f3eed72 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "@tiptap/starter-kit": "3.32.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "dompurify": "3.4.17", "highlight.js": "11.11.1", "lucide-react": "1.31.0", "monaco-editor": "0.56.0", @@ -49,16 +50,16 @@ "openapi-typescript": "7.13.0", "typescript": "5.9.3", "vite": "8.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "overrides": { "@redocly/openapi-core": { "js-yaml": "4.3.2" }, "brace-expansion": "5.0.12", - "dompurify": "3.4.16", + "dompurify": "3.4.17", "nanoid": "3.3.18", "postcss": "8.5.25", - "undici": "7.29.1" + "undici": "7.30.1" } } diff --git a/web/src/lib/monaco-sanitizer.test.ts b/web/src/lib/monaco-sanitizer.test.ts new file mode 100644 index 00000000..a6fb29d0 --- /dev/null +++ b/web/src/lib/monaco-sanitizer.test.ts @@ -0,0 +1,21 @@ +import DOMPurify from "dompurify"; +// @ts-expect-error Monaco ships this runtime sanitizer without declarations. +import { safeSetInnerHtml, sanitizeHtml } from "monaco-editor/base/browser/domSanitize.js"; + +describe("Monaco sanitizer", () => { + it.each(["trusted HTML", "DOM fragment"])("uses the installed DOMPurify for %s", (output) => { + const sanitize = vi.spyOn(DOMPurify, "sanitize"); + const dirty = '

Kept formatting

' + + 'unsafe' + + 'safe'; + const target = document.createElement("div"); + + if (output === "trusted HTML") target.innerHTML = String(sanitizeHtml(dirty)); + else safeSetInnerHtml(target, dirty); + + expect(sanitize).toHaveBeenCalled(); + expect(target.querySelector("strong")).toHaveTextContent("formatting"); + expect(target.querySelector('script, [onerror], a[href^="javascript:"]')).toBeNull(); + expect(target.querySelector('a[href="https://example.com"]')).toHaveTextContent("safe"); + }); +}); diff --git a/web/src/v2/chat-preferences.ts b/web/src/v2/chat-preferences.ts index 1d61eff9..432e8a28 100644 --- a/web/src/v2/chat-preferences.ts +++ b/web/src/v2/chat-preferences.ts @@ -12,6 +12,7 @@ export const shortcutCommands = [ { id: "settings", zh: "打开设置", en: "Open settings", binding: "Mod+," }, { id: "sidebar", zh: "展开/收起侧栏", en: "Toggle sidebar", binding: "Mod+B" }, { id: "composer", zh: "聚焦输入框", en: "Focus composer", binding: "Mod+Shift+I" }, + { id: "modelPicker", zh: "打开模型选择器", en: "Open model picker", binding: "Mod+Shift+M" }, ] as const; export type ShortcutCommand = typeof shortcutCommands[number]["id"]; export interface ChatPreferences { send: SendPreference; shortcuts: Partial> } @@ -73,7 +74,7 @@ export function validShortcut(binding: string, command?: ShortcutCommand) { "Mod+Shift+V", "Mod+Shift+T", "Mod+Shift+N", "Mod+Shift+W", "Mod+Shift+R"].includes(binding); } -export function useChatShortcuts(actions: Record void | boolean>) { +export function useChatShortcuts(actions: Partial void | boolean>>) { const preferences = useChatPreferences(); const latest = useRef({ actions, preferences }); latest.current = { actions, preferences }; @@ -86,12 +87,15 @@ export function useChatShortcuts(actions: Record void | b if (!binding) return; const command = shortcutCommands.find(({ id }) => shortcutBinding(latest.current.preferences, id) === binding); if (!command) return; + const action = latest.current.actions[command.id]; + if (!action) return; const chatNavigation = command.id === "previousChat" || command.id === "nextChat"; - if ((command.id === "findInChat" || chatNavigation) && event.target instanceof Element && + const contextualMenu = chatNavigation || command.id === "modelPicker"; + if ((command.id === "findInChat" || contextualMenu) && event.target instanceof Element && event.target.closest(".monaco-editor, .xterm")) return; - if (chatNavigation && document.querySelector('[role="dialog"], [role="menu"], [role="listbox"], .v2-project-section-menu[open]')) return; + if (contextualMenu && document.querySelector('[role="dialog"], [role="menu"], [role="listbox"], .v2-project-section-menu[open]')) return; // A contextual action may leave the native shortcut available outside its surface. - if (latest.current.actions[command.id]() !== false) event.preventDefault(); + if (action() !== false) event.preventDefault(); }; window.addEventListener("keydown", keydown); return () => window.removeEventListener("keydown", keydown); diff --git a/web/src/v2/components/keyboard-settings.test.tsx b/web/src/v2/components/keyboard-settings.test.tsx index a7cbddc8..4b89ca1f 100644 --- a/web/src/v2/components/keyboard-settings.test.tsx +++ b/web/src/v2/components/keyboard-settings.test.tsx @@ -11,6 +11,60 @@ function Commands({ search, findInChat = vi.fn(), previousChat = vi.fn(), nextCh return ; } +function ModelShortcut({ action }: { action: () => void }) { + useChatShortcuts({ modelPicker: action }); + return null; +} + +it("leaves unregistered model shortcuts available and dispatches to a local registration", () => { + const modelPicker = vi.fn(); + const view = render(); + const key = { key: "m", code: "KeyM", ctrlKey: true, shiftKey: true, cancelable: true }; + const unregistered = new KeyboardEvent("keydown", key); + fireEvent(window, unregistered); + expect(unregistered.defaultPrevented).toBe(false); + expect(screen.getByRole("button", { name: "Change shortcut for Open model picker" })).toHaveTextContent("Ctrl+Shift+M"); + view.rerender(<>); + const registered = new KeyboardEvent("keydown", key); + fireEvent(window, registered); + expect(registered.defaultPrevented).toBe(true); + expect(modelPicker).toHaveBeenCalledOnce(); +}); + +it("records, persists and clears the model picker shortcut without activating it during recording", () => { + const modelPicker = vi.fn(); + const view = render(<>); + const record = screen.getByRole("button", { name: "Change shortcut for Open model picker" }); + fireEvent.click(record); + fireEvent.keyDown(record, { key: "j", code: "KeyJ", ctrlKey: true, shiftKey: true }); + expect(modelPicker).not.toHaveBeenCalled(); + expect(localStorage.getItem(chatPreferencesKey)).toContain('"modelPicker":"Mod+Shift+J"'); + view.unmount(); + render(<>); + const saved = screen.getByRole("button", { name: "Change shortcut for Open model picker" }); + expect(saved).toHaveTextContent("Ctrl+Shift+J"); + fireEvent.keyDown(window, { key: "m", code: "KeyM", ctrlKey: true, shiftKey: true }); + expect(modelPicker).not.toHaveBeenCalled(); + fireEvent.keyDown(window, { key: "j", code: "KeyJ", ctrlKey: true, shiftKey: true }); + expect(modelPicker).toHaveBeenCalledOnce(); + fireEvent.click(saved); + fireEvent.keyDown(saved, { key: "Backspace" }); + expect(saved).toHaveTextContent("Unassigned"); + fireEvent.keyDown(window, { key: "j", code: "KeyJ", ctrlKey: true, shiftKey: true }); + expect(modelPicker).toHaveBeenCalledOnce(); +}); + +it("preserves an older custom binding that uses the new model picker default", () => { + saveChatPreferences({ shortcuts: { settings: "Mod+Shift+M" } }); + const settings = vi.fn(); + const modelPicker = vi.fn(); + render(<>); + expect(screen.getByRole("button", { name: "Change shortcut for Open model picker" })).toHaveTextContent("Unassigned"); + fireEvent.keyDown(window, { key: "m", code: "KeyM", ctrlKey: true, shiftKey: true }); + expect(settings).toHaveBeenCalledOnce(); + expect(modelPicker).not.toHaveBeenCalled(); +}); + it("records, searches, persists and resets shortcuts without invoking actions while recording", () => { const search = vi.fn(); const view = render(); diff --git a/web/src/v2/components/model-route-control.test.tsx b/web/src/v2/components/model-route-control.test.tsx index 5956250c..fcc6bae6 100644 --- a/web/src/v2/components/model-route-control.test.tsx +++ b/web/src/v2/components/model-route-control.test.tsx @@ -4,10 +4,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render as renderComponent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../../api/client"; +import { chatPreferencesKey, saveChatPreferences } from "../chat-preferences"; import { V2ModelRouteControl, type V2ModelRouteCatalog, type V2ThreadModelRoute } from "./model-route-control"; const routeStyles = readFileSync("src/v2/styles.css", "utf8"); +afterEach(() => localStorage.removeItem(chatPreferencesKey)); + const catalog: V2ModelRouteCatalog = { protocol_version: "model_route_catalog.v1", generation: 7, @@ -74,6 +77,87 @@ function renderControl(client: TestRouteClient = routeClient(), options: { } describe("V2ModelRouteControl", () => { + it.each(["ctrlKey", "metaKey"])("opens the model list with %s without selecting a route or changing the draft", async (modifier) => { + const client = routeClient(); + const onManageModels = vi.fn(); + render( + +