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 视口。
+
+
+
+
+
+
+
+
+
+## 聚焦验证
+
+`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(
+
+
+ );
+ const composer = screen.getByRole("textbox", { name: "下一条消息" });
+ act(() => composer.focus());
+ const event = new KeyboardEvent("keydown", { key: "M", code: "KeyM", [modifier]: true,
+ shiftKey: true, bubbles: true, cancelable: true });
+ fireEvent(composer, event);
+ expect(event.defaultPrevented).toBe(true);
+ const models = screen.getByRole("menu", { name: "选择模型路由" });
+ expect(screen.queryByRole("menu", { name: "模型与响应设置" })).not.toBeInTheDocument();
+ expect(within(models).getByRole("menuitem", { name: "返回模型设置" })).toHaveFocus();
+ expect(await within(models).findByRole("menuitemradio", { name: "deepseek-v4-flash" })).toHaveAttribute("aria-checked", "true");
+ expect(within(models).getByRole("menuitemradio", { name: /^gpt-5\.6-terra/u })).toBeDisabled();
+ expect(client.selectThreadModelRoute).not.toHaveBeenCalled();
+ expect(onManageModels).not.toHaveBeenCalled();
+ expect(composer).toHaveValue("保留这份草稿");
+ fireEvent.keyDown(document.activeElement!, { key: "Escape" });
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /模型路由/u })).toHaveFocus();
+ });
+
+ it("uses a remapped model shortcut immediately and leaves an unassigned shortcut available", () => {
+ renderControl();
+ const key = { key: "m", code: "KeyM", ctrlKey: true, shiftKey: true, cancelable: true };
+ act(() => saveChatPreferences({ shortcuts: { modelPicker: "Mod+Shift+J" } }));
+ fireEvent.keyDown(window, key);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ fireEvent.keyDown(window, { ...key, key: "j", code: "KeyJ" });
+ expect(screen.getByRole("menu", { name: "选择模型路由" })).toBeInTheDocument();
+ fireEvent.keyDown(window, { key: "Escape" });
+ act(() => saveChatPreferences({ shortcuts: { modelPicker: "" } }));
+ const event = new KeyboardEvent("keydown", key);
+ fireEvent(window, event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("does not open over another menu, a dialog, recording, editors, terminals or composition", () => {
+ const { client } = renderControl();
+ const key = { key: "m", code: "KeyM", ctrlKey: true, shiftKey: true };
+ for (const className of ["monaco-editor", "xterm"]) {
+ const editor = document.createElement("input"); editor.className = className; document.body.append(editor);
+ fireEvent.keyDown(editor, key);
+ editor.remove();
+ }
+ for (const role of ["dialog", "menu", "listbox"]) {
+ const popup = document.createElement("div"); popup.setAttribute("role", role); document.body.append(popup);
+ fireEvent.keyDown(window, key);
+ popup.remove();
+ }
+ const recorder = document.createElement("button"); recorder.dataset.shortcutRecorder = "true"; document.body.append(recorder);
+ fireEvent.keyDown(recorder, key);
+ recorder.remove();
+ const projectMenu = document.createElement("details");
+ projectMenu.className = "v2-project-section-menu"; projectMenu.open = true; document.body.append(projectMenu);
+ fireEvent.keyDown(window, key);
+ projectMenu.remove();
+ fireEvent.keyDown(window, { ...key, isComposing: true });
+ fireEvent.keyDown(window, { ...key, keyCode: 229 });
+ fireEvent.keyDown(window, { ...key, repeat: true });
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(client.availableModelRoutes).not.toHaveBeenCalled();
+ expect(client.selectThreadModelRoute).not.toHaveBeenCalled();
+ });
+
+ it("leaves the shortcut untouched when model control is unavailable", () => {
+ const { client } = renderControl(routeClient({ hasModelControl: false }));
+ const event = new KeyboardEvent("keydown", { key: "m", code: "KeyM", ctrlKey: true,
+ shiftKey: true, cancelable: true });
+ fireEvent(window, event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(client.availableModelRoutes).not.toHaveBeenCalled();
+ expect(client.selectThreadModelRoute).not.toHaveBeenCalled();
+ });
+
it("keeps only the model basename on the toolbar while retaining the full route and reasoning detail", async () => {
const user = userEvent.setup();
const client = routeClient({ threadModelRoute: vi.fn().mockResolvedValue({ ...current,
diff --git a/web/src/v2/components/model-route-control.tsx b/web/src/v2/components/model-route-control.tsx
index 99522c58..869ee65e 100644
--- a/web/src/v2/components/model-route-control.tsx
+++ b/web/src/v2/components/model-route-control.tsx
@@ -4,6 +4,7 @@ import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert,
LoaderCircle, RotateCcw, Settings } from "lucide-react";
import type { APIClient } from "../../api/client";
import { useLocale } from "../../lib/locale";
+import { useChatShortcuts } from "../chat-preferences";
import type { AvailableModelRouteCollectionView, AvailableModelRouteView,
ThreadModelRouteView } from "../../api/types";
@@ -131,6 +132,11 @@ export function V2ModelRouteControl({ client, threadID, pendingRoute, runActive
const routeKey = ["v2", "thread", threadID, "model-route"] as const;
const catalogKey = ["v2", "models", "available-routes"] as const;
+ useChatShortcuts({ modelPicker: () => {
+ if (!routeControlAvailable) return false;
+ setLevel("models");
+ } });
+
const closeMenu = () => {
// Restore before removing the focused menu item. A later animation frame
// could steal focus from a composer the operator has already returned to.
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 7faf222b..fd8574df 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,7 +1,23 @@
+import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
-import { loadEnv } from "vite";
+import { loadEnv, normalizePath, type Plugin } from "vite";
import { defineConfig } from "vitest/config";
+// npm overrides cannot replace Monaco's embedded copy. Redirect its sanitizer import
+// in both Vite and the separate dependency prebundler to the installed DOMPurify.
+const monacoSanitizer = normalizePath(fileURLToPath(import.meta.resolve("monaco-editor/base/browser/domSanitize.js")));
+const monacoDOMPurify: Plugin = {
+ name: "monaco-dompurify",
+ enforce: "pre",
+ async resolveId(source, importer) {
+ if (source === "./dompurify/dompurify.js" && importer && normalizePath(importer) === monacoSanitizer) {
+ const resolved = await this.resolve("dompurify", importer, { skipSelf: true });
+ if (!resolved) throw new Error("Cannot resolve installed DOMPurify for the Monaco sanitizer");
+ return resolved;
+ }
+ },
+};
+
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const apiTarget = env.CYBERAGENT_API_TARGET || "http://127.0.0.1:8765";
@@ -19,7 +35,10 @@ export default defineConfig(({ mode }) => {
};
return {
- plugins: [react()],
+ plugins: [react(), monacoDOMPurify],
+ optimizeDeps: {
+ rolldownOptions: { plugins: [monacoDOMPurify] },
+ },
server: {
host: "127.0.0.1",
port: 5173,
@@ -39,6 +58,7 @@ export default defineConfig(({ mode }) => {
},
test: {
include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ server: { deps: { inline: ["monaco-editor"] } },
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",