diff --git a/packages/webui/docs/README.md b/packages/webui/docs/README.md index 44948ba44..1fb1f848a 100644 --- a/packages/webui/docs/README.md +++ b/packages/webui/docs/README.md @@ -90,7 +90,10 @@ is the sole decision record for Mini App hosting. ADR 0021 records the model pic with two states, and the cascade it replaces. ADR 0024 records the client-side workspace boundary for a diff body: a diff entry outside the workspace keeps its name and its counts and loses its content, the boundary is lexical, and a diff whose workspace root the client does not know is not -withheld. A new decision takes the next free number. +withheld. ADR 0026 records the composer's keyboard bindings as the settings page stores them: one +canonical notation, the settings page owns the change, the composer reads the stored binding when +a key is pressed, and only the actions that already have keyboard meaning are bindable. A new +decision takes the next free number. | Retired | Was | Why it is not in the log | Where the live facts are | | --- | --- | --- | --- | diff --git a/packages/webui/docs/adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md b/packages/webui/docs/adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md new file mode 100644 index 000000000..bb9759a54 --- /dev/null +++ b/packages/webui/docs/adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md @@ -0,0 +1,77 @@ +# The composer follows the bindings the settings page stores + +**Status:** Accepted and implemented. +**Date:** 2026-10-26. + +The settings page's **快捷键** tab was a label with no panel behind it: `shortcuts` was the one +preference entry declared `disabled: true` next to 语音 and 连接, and the composer decided what +Enter meant in code — `resolveWebuiComposerEnterAction` answered "submit" or "newline" from the +modifier flags alone. A user could not see that rule, could not change it, and a page that lists +actions it cannot bind is a page that lies. + +The settings page now **lists the composer's keyboard actions, shows the key each one is bound to, +and changes the binding**. Two actions are bindable, because two actions have keyboard meaning +today: + +| Action | Built-in binding | +| --- | --- | +| 发送消息 — send the message | `Enter` | +| 换行 — break the line | `Shift+Enter` | + +## What a binding is + +A binding is **one canonical string**: the modifiers in a fixed order, then the base key — +`Control+Shift+Enter`. The same string is what the browser stores, what the page shows and what the +composer matches against, so a binding cannot mean one thing on the page and another in the +composer. The notation, the matching, the conflict rule and the fallback to the built-in bindings +live in one module (`client/projection/common/shortcuts.ts`) that both sides read, because both sides +have to agree on what a binding is. + +The string is not a keymap. It is the smallest representation that lets a stored preference and a +key press be compared without either side inventing its own vocabulary. + +## Who owns the change, and who reads it + +The **settings page owns the change**: it is the only surface that rebinds an action, and it hands +the result to the settings owner, which persists it under `webui-shortcuts.v1` — the same habit as +`webui-theme`, `mavis-locale` and `file_line_wrap`, and a key of its own so it cannot collide with +the language preference. + +The **composer reads it when a key is pressed**, not when it renders. The settings page and the +composer are open at the same time, and a binding changed in the page has to be the binding the +next key press obeys; a value read at press time is that, and a value held in component state would +be whatever it was when the composer last rendered. + +The composer therefore keeps no shortcut state of its own. What it owns is the rule: a press that +names the send binding submits, a press that names the newline binding breaks the line, and a press +that names neither is left to the browser — which is why a key the user unbound stops doing the +composer's business and starts doing the textarea's. + +## What the page refuses + +- **A modifier on its own.** A binding is a combination; capturing `Shift` alone would leave a + shortcut that fires whenever the user holds a key down. +- **A combination another action already holds.** The page says which action holds it and keeps + both bindings as they were. + +## What this is not + +- **Not a keybinding framework.** Two actions, one notation, one page. The voice, connection and + personalization tabs stay disabled, and nothing here changes the server or the runtime. +- **Not a new binding for stopping a generation.** 停止生成 is a button and nothing else, and the + page says so. `Escape` is already spoken for by the menus it closes — the mention menu, the slash + menu, the permission menu, goal mode and plan mode all claim it — so a shortcut for it would + fight them rather than serve the user. +- **Not the language pack.** This page's strings are hard-coded Chinese, like the rest of the + settings page on this baseline. Where the settings page's language pack lands, these strings move + with the others; the two changes touch the same tab table and nothing else. + +## Consequences + +- The composer no longer hard-codes what Enter means. `resolveWebuiComposerEnterAction` is retired: + the rule it encoded is the default configuration, expressed as the same values the page shows. +- The built-in behaviour is unchanged. With nothing stored, `Enter` sends and `Shift+Enter` breaks + the line, and a press that names neither binding is left to the browser — which is what the + retired rule did with every modified Enter. +- A newline bound to a key the browser does not break the line on (`Control+J`, say) is broken by + the composer itself, because a binding the user can set is a binding the user can rely on. diff --git a/packages/webui/docs/client/composer.md b/packages/webui/docs/client/composer.md index cc2ae309e..549b776d6 100644 --- a/packages/webui/docs/client/composer.md +++ b/packages/webui/docs/client/composer.md @@ -13,6 +13,7 @@ needs its own behavioural evidence — browser or assembled-application, per | Queue mirror | One selected destination with a generation, not a per-session mirror map; home display gates preserved | Refresh view interest without recreating the owner | Enqueue / delete / read commit only to an eligible destination generation; settlement obligations survive a selection change | | Retry | Last `{session, message}` retained, shown only for the matching session and refusal, never retargeted by adoption | Retained | Retry enters the ordinary send/enqueue workflow; goals and commands create no turn-retry record | | Mention and slash | Mention retained until an explicit command; slash derived from draft and caret | Recreate caret, index and listeners | Selection/dismiss controls the range; no universal switch or submit reset | +| Send and newline keys | The bindable keys are the stored bindings, not a rule in the component; the settings page owns the change | The stored binding is read when a key is pressed, so a rebinding takes effect without a remount | 发送消息 submits and 换行 breaks the line; a press that names neither binding is left to the browser; an open IME composition belongs to the composition | ## Where the pieces live @@ -29,3 +30,10 @@ needs its own behavioural evidence — browser or assembled-application, per React-side store binding. - The submission path itself (creation, admission, send-or-queue) is [`submission.md`](submission.md). +- The keyboard bindings live in the settings business: the page is + `client/features/settings/ui/ShortcutsSettings.tsx` with its own model in + `client/features/settings/projection/settings-shortcuts.ts`, and the notation both the page and the + composer read is `client/projection/common/shortcuts.ts`. The composer keeps no binding state — + it reads the stored binding when a key is pressed. ADR + [`../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md`](../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md) + records why. diff --git a/packages/webui/src/client/features/composer/bindings/use-composer-input.ts b/packages/webui/src/client/features/composer/bindings/use-composer-input.ts index f3afc95c4..979a8d802 100644 --- a/packages/webui/src/client/features/composer/bindings/use-composer-input.ts +++ b/packages/webui/src/client/features/composer/bindings/use-composer-input.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react"; import { findWebuiMentionRange, removeWebuiSlashToken, type WebuiMentionRange, type WebuiSlashRange } from "../projection/composer-interactions.js"; -import { resolveWebuiComposerEnterAction } from "../projection/composer-state.js"; +import { resolveWebuiComposerShortcutAction } from "../projection/composer-state.js"; +import { parseShortcutBindings, shortcutBaseKey, SHORTCUT_STORAGE_KEY } from "../../../projection/common/shortcuts.js"; +import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js"; import { shouldRecallWebuiHistory, startWebuiHistoryBrowse, stepWebuiHistoryBrowse, type WebuiHistoryBrowse } from "../application/composer-history.js"; export interface ComposerInputKeyOptions { @@ -59,6 +61,10 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp const [mentionIndex, setMentionIndex] = useState(0); const [commandIndex, setCommandIndex] = useState(0); const [historyBrowse, setHistoryBrowse] = useState(); + // The bindings are read when a key is pressed rather than held in state: the + // settings page can change them while the composer is open, and the next + // press must follow the binding the user just set. + const { storage } = useWebuiBrowserCapabilities(); useEffect(() => setHistoryBrowse(undefined), [sessionId]); @@ -85,6 +91,27 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp input.setMentionRange(nextRange); }, []); + /** + * Breaks the line at the caret. + * + * `Enter` breaks the line by itself, so this is only reached for a newline + * bound to a key the browser does not break the line on. + */ + const insertNewline = useCallback((event: KeyboardEvent, options: ComposerInputKeyOptions) => { + const caret = event.currentTarget.selectionStart ?? options.draft.length; + const value = `${options.draft.slice(0, caret)}\n${options.draft.slice(caret)}`; + pendingCaret.current = caret + 1; + edit({ + value, + caret: caret + 1, + changeDraft: options.onHistoryDraftChange ?? options.onDraftChange, + mentionRange: options.mentionRange, + setMentionRange: options.setMentionRange, + setComposerMenu: options.setComposerMenu, + setPermissionMenuOpen: options.setPermissionMenuOpen, + }); + }, [edit]); + const updateCaret: ReturnType["updateCaret"] = useCallback((value, nextCaret, _mentionRange, setMentionRange) => { setCaret(nextCaret); setMentionRange(findWebuiMentionRange(value, nextCaret)); @@ -145,20 +172,31 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp return; } } - if (event.key === "Enter" && options.commandCount === 0) { - const action = resolveWebuiComposerEnterAction({ - shiftKey: event.shiftKey, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, + if (options.commandCount === 0) { + const bindings = parseShortcutBindings(storage.getItem(SHORTCUT_STORAGE_KEY)); + const action = resolveWebuiComposerShortcutAction({ + event: { + key: event.key, + code: event.code, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }, isComposing: event.nativeEvent.isComposing, submitBlocked: options.submitBlocked, + bindings, }); if (action === "submit") { event.preventDefault(); event.currentTarget.form?.requestSubmit(); return; } + if (action === "newline" && shortcutBaseKey(bindings["composer.newline"]) !== "Enter") { + event.preventDefault(); + insertNewline(event, options); + return; + } } if (options.commandCount === 0) return; if (event.key === "ArrowDown") { @@ -171,7 +209,7 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp event.preventDefault(); options.chooseCommand(commandIndex); } - }, [applyHistoryDraft, commandIndex, historyBrowse, mentionIndex]); + }, [applyHistoryDraft, commandIndex, historyBrowse, insertNewline, mentionIndex, storage]); return { textareaRef, caret, setCaret, mentionIndex, setMentionIndex, commandIndex, setCommandIndex, diff --git a/packages/webui/src/client/features/composer/projection/composer-state.ts b/packages/webui/src/client/features/composer/projection/composer-state.ts index fbe71a01a..9fd8231b5 100644 --- a/packages/webui/src/client/features/composer/projection/composer-state.ts +++ b/packages/webui/src/client/features/composer/projection/composer-state.ts @@ -3,6 +3,7 @@ import type { WebuiSlashCommandFields, } from "../contracts/slash-command.js"; import { classifyWebuiSlashCommand } from "../contracts/slash-command.js"; +import { shortcutMatches, type ShortcutBindings, type ShortcutKeyEvent } from "../../../projection/common/shortcuts.js"; export type WebuiSubmissionIntent = | { readonly kind: "activate-goal-mode" } | { readonly kind: "activate-plan-mode" } @@ -55,19 +56,33 @@ export function resolveWebuiSubmissionIntent(args: { return { kind: "submit-turn" }; } -export type WebuiComposerEnterAction = "submit" | "newline"; +export type WebuiComposerShortcutAction = "submit" | "newline" | "ignore"; -export function resolveWebuiComposerEnterAction(args: { - readonly shiftKey: boolean; - readonly altKey: boolean; - readonly ctrlKey: boolean; - readonly metaKey: boolean; +/** + * What a key press in the composer does. + * + * The rule is the configured binding, not a hard-coded one: the settings page + * can bind "send the message" to any combination, and the composer follows it. + * `ignore` is the answer for a press that names no binding at all — the + * browser's own behaviour then stands, which is how a key that is not bound + * keeps doing what a key in a textarea does. + * + * The default bindings (`Enter` sends, `Shift+Enter` breaks the line) reproduce + * the rule this composer had before the bindings were rebindable. + */ +export function resolveWebuiComposerShortcutAction(args: { + readonly event: ShortcutKeyEvent; readonly isComposing: boolean; readonly submitBlocked: boolean; -}): WebuiComposerEnterAction { - if (args.shiftKey || args.altKey || args.ctrlKey || args.metaKey) return "newline"; - if (args.isComposing || args.submitBlocked) return "newline"; - return "submit"; + readonly bindings: ShortcutBindings; +}): WebuiComposerShortcutAction { + // While an input method is composing, Enter belongs to the composition. + if (args.isComposing) return "ignore"; + if (shortcutMatches(args.bindings["composer.submit"], args.event)) { + return args.submitBlocked ? "ignore" : "submit"; + } + if (shortcutMatches(args.bindings["composer.newline"], args.event)) return "newline"; + return "ignore"; } export function deriveRecentWorkspaceDirs( diff --git a/packages/webui/src/client/features/settings/projection/settings-shortcuts.ts b/packages/webui/src/client/features/settings/projection/settings-shortcuts.ts new file mode 100644 index 000000000..d03991d48 --- /dev/null +++ b/packages/webui/src/client/features/settings/projection/settings-shortcuts.ts @@ -0,0 +1,80 @@ +// The shortcuts page's own model: which actions it lists, what each one is +// called and which of them can be bound at all. +// +// The page renders this and the composer reads the bindings themselves, so the +// names live here rather than in the component that draws the rows. + +import { + shortcutDisplayLabel, + type ShortcutActionId, + type ShortcutBindings, +} from "../../../projection/common/shortcuts.js"; + +export interface ShortcutSettingsAction { + readonly actionId: ShortcutActionId; + /** The control's test id. It is a page detail, not a domain name. */ + readonly testId: string; + readonly title: string; + readonly description: string; + /** Whether the row accepts a new binding. */ + readonly editable: boolean; + /** What the row says for an action that has no key binding at all. */ + readonly note?: string; +} + +/** + * The actions the page lists. + * + * Only the two composer actions that already have keyboard meaning are + * bindable. Stopping a generation is a button and nothing else: `Escape` is + * already spoken for by the menus it closes, so the row says so rather than + * offering a binding that would fight them. + */ +export const SHORTCUT_SETTINGS_ACTIONS: readonly ShortcutSettingsAction[] = [ + { + actionId: "composer.submit", + testId: "mavis-settings-shortcut-submit", + title: "发送消息", + description: "在输入框中发送当前消息", + editable: true, + }, + { + actionId: "composer.newline", + testId: "mavis-settings-shortcut-newline", + title: "换行", + description: "在输入框中换行,不发送消息", + editable: true, + }, + { + actionId: "composer.stop", + testId: "mavis-settings-shortcut-stop", + title: "停止生成", + description: "仅按钮,无快捷键", + editable: false, + note: "仅按钮,无快捷键", + }, +]; + +export interface ShortcutSettingsRow extends ShortcutSettingsAction { + /** The action's current binding, in its canonical form. */ + readonly shortcut: string; + /** How the current binding is shown. */ + readonly label: string; +} + +/** The page's rows, for a set of bindings. */ +export function shortcutSettingsRows(bindings: ShortcutBindings): readonly ShortcutSettingsRow[] { + return SHORTCUT_SETTINGS_ACTIONS.map((action) => { + const shortcut = bindings[action.actionId] ?? ""; + return { + ...action, + shortcut, + label: shortcut ? shortcutDisplayLabel(shortcut) : "未设置", + }; + }); +} + +/** The name of an action, for a message that names the action it collided with. */ +export function shortcutActionTitle(actionId: ShortcutActionId): string { + return SHORTCUT_SETTINGS_ACTIONS.find((action) => action.actionId === actionId)?.title ?? actionId; +} diff --git a/packages/webui/src/client/features/settings/projection/settings-view.ts b/packages/webui/src/client/features/settings/projection/settings-view.ts index 56358911c..0093ce718 100644 --- a/packages/webui/src/client/features/settings/projection/settings-view.ts +++ b/packages/webui/src/client/features/settings/projection/settings-view.ts @@ -13,7 +13,7 @@ export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instruc export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; } export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [ -{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" }, +{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts" }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" }, // The browser switches were under the browser panel, in the tool area. A // setting that lives in a panel is a setting nobody finds without opening // that panel first, so they are a page of their own — under 偏好, next to diff --git a/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx b/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx index a9fa7ee27..091c95839 100644 --- a/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx +++ b/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx @@ -54,6 +54,8 @@ import { selectWebuiCatalogFlatPage } from "../../../features/sessions/applicati import { BrowserSettingsPage } from "../../browser/ui/BrowserSettingsPage.public.js"; import { DESKTOP_SETTINGS_TABS, resolveThemePreference, filterSettingsTabs, SETTINGS_GROUPS } from "../projection/settings-view.js"; import type { SettingsTabKey } from "../projection/settings-view.js"; +import { DEFAULT_SHORTCUT_BINDINGS, parseShortcutBindings, serializeShortcutBindings, SHORTCUT_STORAGE_KEY } from "../../../projection/common/shortcuts.js"; +import { ShortcutsSettings } from "./ShortcutsSettings.js"; import { WebuiZoneBoundary } from "../../../shell/WebuiZoneBoundary.js"; import { WebuiWorktreePanel } from "./SettingsModal.js"; @@ -127,9 +129,9 @@ export function SettingsModal({ open, onClose, dataDir, version, sessionId, work const models = modelsState.models.value ?? []; const account = accountState.account.value; const archived = archivedState.sessions; - const [active, setActive] = useState("desktop"); const [query, setQuery] = useState(""); const [theme, setTheme] = useState(() => stored("webui-theme", "light", storage)); const [systemDark, setSystemDark] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches); const [language] = useState(() => stored("mavis-locale", "en", storage)); const [wrap, setWrap] = useState(() => stored("file_line_wrap", "true", storage) === "true"); const [newTab, setNewTab] = useState(() => stored("file_open_in_new_tab", "false", storage) === "true"); const [contextWindow, setContextWindow] = useState(() => stored("webui-context-window-usage", "true", storage) !== "false"); const actualTheme = resolveThemePreference(theme, systemDark); + const [active, setActive] = useState("desktop"); const [query, setQuery] = useState(""); const [theme, setTheme] = useState(() => stored("webui-theme", "light", storage)); const [systemDark, setSystemDark] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches); const [language] = useState(() => stored("mavis-locale", "en", storage)); const [wrap, setWrap] = useState(() => stored("file_line_wrap", "true", storage) === "true"); const [newTab, setNewTab] = useState(() => stored("file_open_in_new_tab", "false", storage) === "true"); const [contextWindow, setContextWindow] = useState(() => stored("webui-context-window-usage", "true", storage) !== "false"); const [shortcuts, setShortcuts] = useState(() => parseShortcutBindings(storage.getItem(SHORTCUT_STORAGE_KEY))); const actualTheme = resolveThemePreference(theme, systemDark); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const media = window.matchMedia("(prefers-color-scheme: dark)"); const listener = () => setSystemDark(media.matches); listener(); media.addEventListener?.("change", listener); return () => media.removeEventListener?.("change", listener); }, []); - useEffect(() => { dom.updateDocumentAppearance(actualTheme, language); storage.setItem("webui-theme", theme); storage.setItem("mavis-locale", language); storage.setItem("file_line_wrap", String(wrap)); storage.setItem("file_open_in_new_tab", String(newTab)); storage.setItem("webui-context-window-usage", String(contextWindow)); dom.dispatchContextWindowUsageChange(); }, [actualTheme, contextWindow, dom, language, newTab, storage, theme, wrap]); + useEffect(() => { dom.updateDocumentAppearance(actualTheme, language); storage.setItem("webui-theme", theme); storage.setItem("mavis-locale", language); storage.setItem("file_line_wrap", String(wrap)); storage.setItem("file_open_in_new_tab", String(newTab)); storage.setItem("webui-context-window-usage", String(contextWindow)); storage.setItem(SHORTCUT_STORAGE_KEY, serializeShortcutBindings(shortcuts)); dom.dispatchContextWindowUsageChange(); }, [actualTheme, contextWindow, dom, language, newTab, shortcuts, storage, theme, wrap]); useEffect(() => { if (!open) return; void modelsWorkflows?.loadModels(sessionId); void accountWorkflows?.loadAccount(sessionId); void sessionWorkflows?.loadArchived(); }, [accountWorkflows, modelsWorkflows, open, sessionId, sessionWorkflows]); const visibleTabs = useMemo(() => filterSettingsTabs(query), [query]); if (!open) return null; const selected = models.find((model) => model.selected); const modelValue = selected ? `${selected.providerId}/${selected.modelId}/${selected.variant ?? ""}` : ""; const groups = SETTINGS_GROUPS.map((group) => ({ ...group, tabs: visibleTabs.filter((tab) => tab.group === group.key) })).filter((group) => group.tabs.length > 0); const label = DESKTOP_SETTINGS_TABS.find((tab) => tab.key === active)?.label; // The request is Models' to build: the option value and the selection are two @@ -138,7 +140,7 @@ export function SettingsModal({ open, onClose, dataDir, version, sessionId, work // not arrive here. const changeModel = async (value: string) => { const model = webuiModelFromOptionValue(models, value); if (!model || !modelsWorkflows) return; await modelsWorkflows.selectModel(buildWebuiModelSelectionRequest(model, {}, sessionId)); }; const handleDeleteAllArchived = async () => { if (!sessionWorkflows || !archived.length || !window.confirm("确定删除全部已归档任务吗?此操作无法撤销")) return; const ids = archived.map((session) => session.sessionId); try { await Promise.all(ids.map((id) => sessionWorkflows.removeArchived(id))); } catch (error) { window.alert(`删除失败:${error instanceof Error ? error.message : String(error)}`); } }; - return
{ if (event.target === event.currentTarget) onClose(); }}>
event.stopPropagation()}>

{label}

{active === "archived" ? : null}{active === "account" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "browser" ? : null}{active === "usage" ? : null}{active === "account" ? : null}{active === "archived" ? { if (!sessionWorkflows) return; await sessionWorkflows.removeArchived(id); }} onUnarchive={async (id) => { await sessionWorkflows?.unarchive(id); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "custom-instructions" && active !== "browser" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

: null}
; + return
{ if (event.target === event.currentTarget) onClose(); }}>
event.stopPropagation()}>

{label}

{active === "archived" ? : null}{active === "account" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "shortcuts" ? setShortcuts(DEFAULT_SHORTCUT_BINDINGS)} /> : null}{active === "browser" ? : null}{active === "usage" ? : null}{active === "account" ? : null}{active === "archived" ? { if (!sessionWorkflows) return; await sessionWorkflows.removeArchived(id); }} onUnarchive={async (id) => { await sessionWorkflows?.unarchive(id); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "custom-instructions" && active !== "browser" && active !== "shortcuts" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

: null}
; } function GenericPage({ theme, setTheme, wrap, setWrap, newTab, setNewTab, contextWindow, setContextWindow, version }: { readonly theme: string; readonly setTheme: (value: string) => void; readonly wrap: boolean; readonly setWrap: (value: boolean) => void; readonly newTab: boolean; readonly setNewTab: (value: boolean) => void; readonly contextWindow: boolean; readonly setContextWindow: (value: boolean) => void; readonly version: string }): ReactElement { diff --git a/packages/webui/src/client/features/settings/ui/ShortcutsSettings.tsx b/packages/webui/src/client/features/settings/ui/ShortcutsSettings.tsx new file mode 100644 index 000000000..ce45695c0 --- /dev/null +++ b/packages/webui/src/client/features/settings/ui/ShortcutsSettings.tsx @@ -0,0 +1,104 @@ +// The shortcuts page: the actions a user can rebind, the key each one is bound +// to, and the control that captures a new one. +// +// The page is the only place a binding is changed. It hands the result up +// rather than writing to browser storage itself, so the owner of the +// preference decides when and where it is persisted — the same arrangement the +// other settings rows use. +// +// The capture owns the keyboard while it is open. A key press that names a +// binding is consumed here instead of reaching the app, because the press is +// the user naming a shortcut, not the user using one. + +import { Fragment, useEffect, useMemo, useState, type ReactElement } from "react"; +import { + findShortcutConflict, + parseShortcut, + shortcutDisplayLabel, + type ShortcutActionId, + type ShortcutBindings, +} from "../../../projection/common/shortcuts.js"; +import { SettingsDivider, SettingsPage, SettingsRow, SettingsSection } from "../../../ui/settings-layout.js"; +import { SettingsButton } from "./settings-atoms.js"; +import { shortcutActionTitle, shortcutSettingsRows } from "../projection/settings-shortcuts.js"; + +export interface ShortcutsSettingsProps { + readonly bindings: ShortcutBindings; + readonly onChange: (bindings: ShortcutBindings) => void; + readonly onReset: () => void; +} + +export function ShortcutsSettings({ bindings, onChange, onReset }: ShortcutsSettingsProps): ReactElement { + const [capturing, setCapturing] = useState(undefined); + const [error, setError] = useState(""); + const rows = useMemo(() => shortcutSettingsRows(bindings), [bindings]); + + useEffect(() => { + if (!capturing) return; + const onKeyDown = (event: KeyboardEvent): void => { + // Tab keeps its own meaning while the capture is open: it is how the + // user leaves the control, and a shortcut that swallows it would leave + // them stuck on the button they are editing. + if (event.key !== "Tab") event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + setCapturing(undefined); + setError(""); + return; + } + const shortcut = parseShortcut(event); + // A modifier on its own names no combination, so it is not captured. + if (!shortcut) return; + const conflict = findShortcutConflict(bindings, capturing, shortcut); + if (conflict) { + setError(`「${shortcutDisplayLabel(shortcut)}」已被「${shortcutActionTitle(conflict)}」占用`); + return; + } + onChange({ ...bindings, [capturing]: shortcut }); + setCapturing(undefined); + setError(""); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [bindings, capturing, onChange]); + + return ( + + { setError(""); setCapturing(undefined); onReset(); }}> + 恢复默认 + + } + > + {error ?

{error}

: null} + {rows.map((row, index) => ( + + {index > 0 ? : null} + + {row.editable ? ( + + ) : ( + + {row.note ?? row.label} + + )} + + + ))} +
+
+ ); +} diff --git a/packages/webui/src/client/features/settings/ui/settings.css b/packages/webui/src/client/features/settings/ui/settings.css index 809dccaee..e0210415b 100644 --- a/packages/webui/src/client/features/settings/ui/settings.css +++ b/packages/webui/src/client/features/settings/ui/settings.css @@ -388,6 +388,20 @@ .mavis-settings-appearance-row .webui-settings-row-control { display: flex; } .mavis-settings-theme-selector { display: flex; align-items: flex-start; gap: var(--spacing_12); } .mavis-settings-theme-option { display: flex; width: 100px; flex: 0 0 100px; flex-direction: column; align-items: center; gap: var(--spacing_8); border: 0; background: transparent; padding: 0; color: var(--text_default_secondary); font-size: var(--size_12); line-height: var(--line_height_16); } + /* The shortcut chip. It is a key the user reads, so it is set in the + * monospace face the composer uses for its own key hints: a combination + * like `Ctrl + Enter` is a sequence of names, and names read better when + * they are not the body face. */ + .mavis-settings-shortcut-key { display: inline-flex; min-width: 104px; height: 30px; align-items: center; justify-content: center; border: 1px solid var(--border_default); border-radius: var(--radius_8); background: var(--bg_interaction_secondary_default); padding: 0 var(--spacing_12); color: var(--text_default_primary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--size_12); line-height: var(--line_height_16); } + .mavis-settings-shortcut-key:hover:not(:disabled) { background: var(--bg_interaction_secondary_hover); } + .mavis-settings-shortcut-key:focus-visible { outline: 2px solid var(--border_accent); outline-offset: 2px; } + /* While the chip is listening for a combination, it says so rather than + * showing the key it is about to be replaced with. */ + .mavis-settings-shortcut-key.is-capturing { border-color: var(--border_accent); color: var(--text_default_secondary); } + /* An action with no binding is a fact, not a control: it gets the row's + * secondary text and no surface to click. */ + .mavis-settings-shortcut-key.is-static { border-color: transparent; background: transparent; color: var(--text_default_secondary); } + .mavis-settings-shortcut-error { margin: 0; padding: var(--spacing_8) var(--spacing_12); color: var(--text_label_danger_secondary_default); font-size: var(--size_12); line-height: var(--line_height_16); } .mavis-settings-theme-preview { display: block; width: 100px; height: 64px; overflow: hidden; border-radius: var(--radius_8); padding: var(--spacing_4); outline: 2px solid var(--border_light); outline-offset: -2px; } .mavis-settings-theme-preview.is-selected { outline-color: var(--icon_default_accent); } .mavis-settings-theme-preview > span { display: block; width: 100%; height: 100%; overflow: hidden; border-radius: 6px; outline: 1px solid var(--border_light); outline-offset: -1px; } diff --git a/packages/webui/src/client/projection/common/shortcuts.ts b/packages/webui/src/client/projection/common/shortcuts.ts new file mode 100644 index 000000000..53cec46dc --- /dev/null +++ b/packages/webui/src/client/projection/common/shortcuts.ts @@ -0,0 +1,272 @@ +// Keyboard shortcut bindings: the notation, the matching and the conflicts. +// +// The settings page owns the page and the composer owns the key handling, but +// both have to agree on what a binding *is*: which key combination names +// which action, whether a captured combination is usable at all, and which +// combinations are already taken. That agreement is a value rather than a +// behaviour of either side, so it lives here, in the one layer both may read. +// +// The notation is one canonical string — modifiers in a fixed order, then the +// base key (`Control+Shift+Enter`). The same string is what the browser +// stores, what the page displays and what the composer matches against, so a +// binding cannot mean one thing on the page and another in the composer. + +/** The actions a shortcut can be bound to. */ +export type ShortcutActionId = "composer.submit" | "composer.newline" | "composer.stop"; + +/** The actions, in the order the settings page lists them. */ +export const SHORTCUT_ACTION_IDS: readonly ShortcutActionId[] = [ + "composer.submit", + "composer.newline", + "composer.stop", +]; + +/** + * The part of a keyboard event a binding is read from. + * + * It is the shape both a DOM `KeyboardEvent` and a test's literal already + * have, so nothing here needs a DOM to be exercised. + */ +export interface ShortcutKeyEvent { + readonly key: string; + /** The physical key. It is what keeps `A` and `Shift+A` apart. */ + readonly code?: string; + readonly ctrlKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +/** What every action is bound to. */ +export type ShortcutBindings = Readonly>; + +/** Where the bindings live in browser storage. */ +export const SHORTCUT_STORAGE_KEY = "webui-shortcuts.v1"; + +/** + * The built-in bindings: Enter sends the message, Shift+Enter breaks the line. + * + * They are the behaviour the composer had before the settings page could + * change it, expressed as the same values the page shows. + */ +export const DEFAULT_SHORTCUT_BINDINGS: ShortcutBindings = Object.freeze({ + "composer.submit": "Enter", + "composer.newline": "Shift+Enter", + "composer.stop": "", +}); + +type ShortcutModifier = "Control" | "Alt" | "Shift" | "Meta"; + +/** The order modifiers are written in, so two equal bindings are equal strings. */ +const MODIFIER_ORDER: readonly ShortcutModifier[] = ["Control", "Alt", "Shift", "Meta"]; + +/** How a modifier is spelled when it is read rather than written. */ +const MODIFIER_ALIASES: Readonly> = { + ctrl: "Control", + control: "Control", + alt: "Alt", + option: "Alt", + shift: "Shift", + meta: "Meta", + cmd: "Meta", + command: "Meta", + super: "Meta", + win: "Meta", +}; + +/** How a modifier is spelled when it is shown. */ +const DISPLAY_MODIFIERS: Readonly> = { + Control: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Meta: "Cmd", +}; + +/** Keys that are only ever a modifier, and so can never be a binding on their own. */ +const MODIFIER_KEYS: ReadonlySet = new Set([ + "Control", + "Alt", + "Shift", + "Meta", + "AltGraph", + "CapsLock", + "NumLock", + "ScrollLock", + "Fn", + "FnLock", + "Hyper", + "Super", + "Symbol", + "SymbolLock", + "OS", +]); + +/** The named keys, written the way the browser spells them. */ +const NAMED_KEYS: Readonly> = { + enter: "Enter", + escape: "Escape", + esc: "Escape", + tab: "Tab", + backspace: "Backspace", + delete: "Delete", + space: "Space", + " ": "Space", + arrowup: "ArrowUp", + arrowdown: "ArrowDown", + arrowleft: "ArrowLeft", + arrowright: "ArrowRight", + home: "Home", + end: "End", + pageup: "PageUp", + pagedown: "PageDown", + insert: "Insert", +}; + +/** The canonical spelling of a base key, or undefined when it cannot be one. */ +function normalizeBaseKey(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (MODIFIER_KEYS.has(trimmed)) return undefined; + const lower = trimmed.toLowerCase(); + const named = NAMED_KEYS[lower]; + if (named) return named; + if (/^f\d{1,2}$/u.test(lower)) return lower.toUpperCase(); + if (trimmed.length === 1) return trimmed.toUpperCase(); + return trimmed; +} + +function canonicalShortcut(modifiers: ReadonlySet, base: string): string { + const parts: string[] = []; + for (const modifier of MODIFIER_ORDER) if (modifiers.has(modifier)) parts.push(modifier); + parts.push(base); + return parts.join("+"); +} + +/** + * The canonical form of a written binding, or undefined when it is not one. + * + * It accepts what a person would write (`ctrl+enter`, `Shift + Enter`) and + * returns what the page and the composer agree on (`Control+Enter`). + */ +export function normalizeShortcut(shortcut: string): string | undefined { + const parts = shortcut.split("+").map((part) => part.trim()).filter((part) => part.length > 0); + if (parts.length === 0) return undefined; + const base = normalizeBaseKey(parts[parts.length - 1] ?? ""); + if (!base) return undefined; + const modifiers = new Set(); + for (const modifier of parts.slice(0, -1)) { + const canonical = MODIFIER_ALIASES[modifier.toLowerCase()]; + if (!canonical) return undefined; + modifiers.add(canonical); + } + return canonicalShortcut(modifiers, base); +} + +/** The base key of a binding: `Control+Enter` and `Enter` both answer `Enter`. */ +export function shortcutBaseKey(shortcut: string): string | undefined { + const normalized = normalizeShortcut(shortcut); + if (!normalized) return undefined; + return normalized.slice(normalized.lastIndexOf("+") + 1); +} + +/** The base key of a key press, or undefined when the press carries none. */ +function baseKeyOf(event: ShortcutKeyEvent): string | undefined { + const code = event.code ?? ""; + const letter = /^Key([A-Z])$/u.exec(code); + if (letter) return letter[1] ?? undefined; + const digit = /^Digit([0-9])$/u.exec(code); + if (digit) return digit[1] ?? undefined; + if (code === "Space") return "Space"; + return normalizeBaseKey(event.key); +} + +/** + * The binding a key press names, or undefined when it names none. + * + * A press of a modifier on its own names nothing: a binding is a combination, + * and capturing `Shift` alone would leave the user with a shortcut that fires + * whenever they hold a key down. + */ +export function parseShortcut(event: ShortcutKeyEvent): string | undefined { + const base = baseKeyOf(event); + if (!base) return undefined; + const modifiers = new Set(); + if (event.ctrlKey) modifiers.add("Control"); + if (event.altKey) modifiers.add("Alt"); + if (event.shiftKey) modifiers.add("Shift"); + if (event.metaKey) modifiers.add("Meta"); + return canonicalShortcut(modifiers, base); +} + +/** Whether a key press is the one a binding names. */ +export function shortcutMatches(shortcut: string, event: ShortcutKeyEvent): boolean { + const normalized = normalizeShortcut(shortcut); + if (!normalized) return false; + return normalized === parseShortcut(event); +} + +/** + * The action a binding would collide with, if any. + * + * The action's own current binding is not a collision — rebinding an action to + * what it already has changes nothing. + */ +export function findShortcutConflict( + bindings: ShortcutBindings, + actionId: ShortcutActionId, + shortcut: string, +): ShortcutActionId | undefined { + const target = normalizeShortcut(shortcut); + if (!target) return undefined; + for (const candidate of SHORTCUT_ACTION_IDS) { + if (candidate === actionId) continue; + const value = normalizeShortcut(bindings[candidate]); + if (value && value === target) return candidate; + } + return undefined; +} + +/** + * The bindings a stored value describes. + * + * A value that cannot be read, or a binding that cannot be one, falls back to + * the default for that action: a corrupt preference must not leave the + * composer without a way to send a message. + */ +export function parseShortcutBindings(raw: string | null | undefined): ShortcutBindings { + if (!raw) return DEFAULT_SHORTCUT_BINDINGS; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return DEFAULT_SHORTCUT_BINDINGS; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return DEFAULT_SHORTCUT_BINDINGS; + const source = parsed as Record; + const next: Record = { ...DEFAULT_SHORTCUT_BINDINGS }; + for (const action of SHORTCUT_ACTION_IDS) { + const value = source[action]; + if (typeof value !== "string") continue; + const normalized = normalizeShortcut(value); + if (normalized) next[action] = normalized; + } + return next; +} + +/** The stored form of a set of bindings. */ +export function serializeShortcutBindings(bindings: ShortcutBindings): string { + const next: Record = {}; + for (const action of SHORTCUT_ACTION_IDS) + next[action] = normalizeShortcut(bindings[action]) ?? DEFAULT_SHORTCUT_BINDINGS[action]; + return JSON.stringify(next); +} + +/** How a binding is shown on the page. */ +export function shortcutDisplayLabel(shortcut: string): string { + const normalized = normalizeShortcut(shortcut); + if (!normalized) return "未设置"; + const parts = normalized.split("+"); + const base = parts.pop() ?? ""; + const modifiers = parts.map((part) => DISPLAY_MODIFIERS[part as ShortcutModifier] ?? part); + return [...modifiers, base].join(" + "); +} diff --git a/packages/webui/test/browser/settings-account-tab.spec.mjs b/packages/webui/test/browser/settings-account-tab.spec.mjs index 75f0b8135..8f8876ebc 100644 --- a/packages/webui/test/browser/settings-account-tab.spec.mjs +++ b/packages/webui/test/browser/settings-account-tab.spec.mjs @@ -115,13 +115,13 @@ test("the account tab is enabled where the unfinished tabs are still disabled", // The other half, and the reason the assertion above is not vacuous: the nav // really does still ship disabled items, so "enabled" is a fact about this // tab rather than a property of every button in the sidebar. A nav that - // silently enabled 语音/快捷键/连接 would pass the first two lines. + // silently enabled 语音/连接 would pass the first two lines. // - // 代码审查 and 工作树 left this list when their pages were built — they are - // real panels now, not empty panes behind a clickable label. The three that - // remain have no content behind them, which is what the `disabled` gate is - // still for. - for (const key of ["voice", "shortcuts", "connection"]) { + // 代码审查, 工作树 and 快捷键 left this list when their pages were built — + // they are real panels now, not empty panes behind a clickable label. The two + // that remain have no content behind them, which is what the `disabled` gate + // is still for. + for (const key of ["voice", "connection"]) { await expect(settingsNavItem(page, key)).toBeDisabled(); } // The app behind the modal is still mounted: the modal is a `document.body` diff --git a/packages/webui/test/browser/settings-shortcuts.spec.mjs b/packages/webui/test/browser/settings-shortcuts.spec.mjs new file mode 100644 index 000000000..b1009883a --- /dev/null +++ b/packages/webui/test/browser/settings-shortcuts.spec.mjs @@ -0,0 +1,241 @@ +// Settings › 快捷键 — the shortcuts page, driven the way a user drives it. +// +// The page's own promise is that a binding it shows is the binding the +// composer obeys, so the tests here follow one binding through the whole +// round trip: capture it on the page, see it survive a close and a reload, +// and then press the key in the composer and watch the message go — or +// watch it stay. +// +// The evidence is the built client and the in-page fixture transport: the +// fixture records what reached the wire, and the transcript is what the user +// sees afterwards. + +import { expect } from "@playwright/test"; + +import { emitStream, openApp, requestCount, test } from "./harness.mjs"; + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message)); + page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); }); +}); + +function settingsDialog(page) { + return page.locator('[role="dialog"][aria-label="设置"]'); +} + +function settingsNavItem(page, key) { + return page.locator(`[data-menu-key="${key}"]`); +} + +/** Opens settings the way a user does: user menu, then 设置. */ +async function openSettings(page) { + await page.locator('[data-testid="sidebar-user-menu-trigger"]').click(); + await page.locator('[data-testid="user-menu-settings"]').click(); + await expect(settingsDialog(page)).toBeVisible(); +} + +/** Leaves the settings the way a user does: the 返回应用 button. */ +async function closeSettings(page) { + await page.locator('[aria-label="返回"]').click(); + await expect(settingsDialog(page)).toHaveCount(0); +} + +function composer(page) { + return page.getByPlaceholder("输入消息…(输入 / 唤起命令)"); +} + +/** The chip that shows an action's current binding, and captures a new one. */ +function shortcutKey(page, action) { + return page.locator(`[data-testid="mavis-settings-shortcut-${action}"]`); +} + +/** The stored bindings, as the browser holds them. */ +function storedShortcuts(page) { + return page.evaluate(() => localStorage.getItem("webui-shortcuts.v1")); +} + +/** Opens the shortcuts page. */ +async function openShortcuts(page) { + await settingsNavItem(page, "shortcuts").click(); + await expect(page.locator('[data-testid="settings-shortcuts-page"]')).toBeVisible(); +} + +/** Rebinds an action to a combination, the way the page captures one. */ +async function rebind(page, action, combination) { + await shortcutKey(page, action).click(); + await expect(shortcutKey(page, action)).toHaveText("按下组合键…"); + await page.keyboard.press(combination); +} + +/** The texts the client sent, as the fixture recorded them. */ +function sentContent(page) { + return page.evaluate(() => + window.__fixture.requests + .filter((request) => request.operation === "sendMessage") + .map((request) => request.body.content), + ); +} + +/** + * The server's echo of a submitted message. + * + * The client sends a message and the turn comes back on the stream; the + * transcript shows what the server echoed. Echoing it here is what puts the + * submitted text in the transcript the user reads. + */ +async function echoTurn(page, sessionId, text) { + await emitStream(page, sessionId, { + dataJson: JSON.stringify({ type: "agent_message", agent_message: { msg_id: `msg-user-${text}`, msg_content: text, role: "user" } }), + }); +} + +/** Ends the live turn, so the composer can take the next submission. */ +async function endTurn(page, sessionId) { + await emitStream(page, sessionId, { dataJson: "[DONE]" }); +} + +test("the shortcuts tab opens and lists the actions with their current bindings", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + + // The tab is a real page now, not a label that leads nowhere. + await expect(page.locator('[data-testid="shortcuts-section"]')).toBeVisible(); + // And it is the only thing on the panel: the empty-page fallback that + // covers the tabs with no content behind them must not appear beside it. + await expect(page.locator('[aria-label="空设置面板"]')).toHaveCount(0); + await expect(shortcutKey(page, "submit")).toHaveText("Enter"); + await expect(shortcutKey(page, "newline")).toHaveText("Shift + Enter"); + // Stopping a generation is a button and nothing else, and the row says so + // rather than offering a binding that would fight the Escape the menus own. + await expect(shortcutKey(page, "stop")).toHaveText("仅按钮,无快捷键"); +}); + +test("a captured combination becomes the binding the page shows", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + + await rebind(page, "submit", "Control+Enter"); + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + + // A combination that is already taken is refused, and the row that holds it + // is named. The capture stays open so the user can press another one; + // Escape leaves it, and the binding is the one it was. + await rebind(page, "newline", "Control+Enter"); + await expect(page.locator('[data-testid="mavis-settings-shortcut-error"]')).toContainText("发送消息"); + await page.keyboard.press("Escape"); + await expect(shortcutKey(page, "newline")).toHaveText("Shift + Enter"); + await expect(page.locator('[data-testid="mavis-settings-shortcut-error"]')).toHaveCount(0); +}); + +test("the binding survives closing the settings and coming back", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + await rebind(page, "submit", "Control+Enter"); + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + + await closeSettings(page); + await openSettings(page); + await openShortcuts(page); + + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + // The page's own state would survive a close, so the stored value is what + // makes this a persistence claim rather than a rendering one. + expect(await storedShortcuts(page)).toContain("Control+Enter"); +}); + +test("the binding survives a reload", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + await rebind(page, "submit", "Control+Enter"); + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + + await page.reload(); + await expect(page.locator("#webui-root")).toBeVisible(); + await openSettings(page); + await openShortcuts(page); + + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + expect(await storedShortcuts(page)).toContain("Control+Enter"); +}); + +test("the composer sends on the new binding and no longer on the old one", async ({ page }) => { + await openApp(page, "#session=A"); + const input = composer(page); + + // The control leg: under the built-in binding, Enter sends. Without it, the + // assertions below would pass against a composer that never sends at all. + await input.click(); + await input.pressSequentially("默认回车发送"); + await input.press("Enter"); + await expect.poll(() => sentContent(page)).toContain("默认回车发送"); + await echoTurn(page, "A", "默认回车发送"); + await expect(page.locator('[data-webui-transcript="A"]')).toContainText("默认回车发送"); + await endTurn(page, "A"); + + await openSettings(page); + await openShortcuts(page); + await rebind(page, "submit", "Control+Enter"); + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + await closeSettings(page); + + // The new binding sends: the text the user typed is what reached the wire, + // and the transcript shows the turn that carried it. + await input.click(); + await input.pressSequentially("新键发送"); + await input.press("Control+Enter"); + await expect.poll(() => sentContent(page)).toContain("新键发送"); + await expect(input).toHaveValue(""); + await echoTurn(page, "A", "新键发送"); + await expect(page.locator('[data-webui-transcript="A"]')).toContainText("新键发送"); + await endTurn(page, "A"); + + // The old one does not: the draft is still there, with the line break the + // browser breaks it with, and nothing was sent for it. + await input.click(); + await input.pressSequentially("旧键不发"); + await input.press("Enter"); + await expect(input).toHaveValue("旧键不发\n"); + expect(await sentContent(page)).not.toContain("旧键不发"); + await expect(page.locator('[data-webui-transcript="A"]')).not.toContainText("旧键不发"); +}); + +test("恢复默认 puts the built-in binding back and the composer follows it", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + await rebind(page, "submit", "Control+Enter"); + await expect(shortcutKey(page, "submit")).toHaveText("Ctrl + Enter"); + + await page.locator('[data-testid="mavis-settings-shortcuts-reset"]').click(); + await expect(shortcutKey(page, "submit")).toHaveText("Enter"); + await closeSettings(page); + + // The behaviour followed the binding back: Enter sends again. + const input = composer(page); + await input.click(); + await input.pressSequentially("回退后发送"); + await input.press("Enter"); + await expect.poll(() => sentContent(page)).toContain("回退后发送"); + await echoTurn(page, "A", "回退后发送"); + await expect(page.locator('[data-webui-transcript="A"]')).toContainText("回退后发送"); +}); + +test("a newline bound to a key the browser does not break the line on breaks it anyway", async ({ page }) => { + await openApp(page, "#session=A"); + await openSettings(page); + await openShortcuts(page); + await rebind(page, "newline", "Control+J"); + await expect(shortcutKey(page, "newline")).toHaveText("Ctrl + J"); + await closeSettings(page); + + const input = composer(page); + await input.click(); + await input.pressSequentially("换行键"); + await input.press("Control+J"); + await expect(input).toHaveValue("换行键\n"); + await expect.poll(() => requestCount(page, "sendMessage")).toBe(0); +}); diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/settings-dialog-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/settings-dialog-linux.png index dd2d41d50..48c0e223f 100644 Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/settings-dialog-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/settings-dialog-linux.png differ diff --git a/packages/webui/test/gates/fixtures/path-metadata-f00.json b/packages/webui/test/gates/fixtures/path-metadata-f00.json index dc40202c7..9cf1d6d54 100644 --- a/packages/webui/test/gates/fixtures/path-metadata-f00.json +++ b/packages/webui/test/gates/fixtures/path-metadata-f00.json @@ -1884,6 +1884,15 @@ "business": "settings", "home": "target" }, + { + "path": "client/features/settings/projection/settings-shortcuts.ts", + "layers": [ + "view" + ], + "role": "feature-projection", + "business": "settings", + "home": "target" + }, { "path": "client/features/settings/projection/settings-view.ts", "layers": [ @@ -1974,6 +1983,15 @@ "business": "settings", "home": "target" }, + { + "path": "client/features/settings/ui/ShortcutsSettings.tsx", + "layers": [ + "bindings" + ], + "role": "feature-ui", + "business": "settings", + "home": "target" + }, { "path": "client/features/settings/ui/UsageHeatmap.tsx", "layers": [ @@ -3189,6 +3207,15 @@ "business": null, "home": "current" }, + { + "path": "client/projection/common/shortcuts.ts", + "layers": [ + "view" + ], + "role": "projection-common", + "business": null, + "home": "current" + }, { "path": "client/projection/common/unread-badge.ts", "layers": [ diff --git a/packages/webui/test/unit/composer-intent.test.ts b/packages/webui/test/unit/composer-intent.test.ts index cf37e678c..8a7f90b91 100644 --- a/packages/webui/test/unit/composer-intent.test.ts +++ b/packages/webui/test/unit/composer-intent.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from "vitest"; import { createElement } from "react"; import { deriveRecentWorkspaceDirs, - resolveWebuiComposerEnterAction, + resolveWebuiComposerShortcutAction, resolveWebuiSubmissionIntent, type WebuiSubmissionIntent, } from "../../src/client/features/composer/projection/composer-state.js"; +import { + DEFAULT_SHORTCUT_BINDINGS, + type ShortcutBindings, + type ShortcutKeyEvent, +} from "../../src/client/projection/common/shortcuts.js"; import { isTurnLive } from "../../src/client/features/turn/contracts/stream-state.js"; import type { SlashCommandEntry } from "../../src/client/features/composer/ui/slash-palette.js"; import { @@ -596,94 +601,90 @@ describe("deriveRecentWorkspaceDirs — the 最近 group in the workspace picker }); /** - * Enter-to-send in the composer textarea. + * What a key press in the composer does. * - * resolveWebuiComposerEnterAction(key, gates) → "submit" | "newline" + * resolveWebuiComposerShortcutAction(key, gates, bindings) + * → "submit" | "newline" | "ignore" * - * The taxonomy is two-valued on purpose: `newline` is the absence of an - * action, and the component reads it as "do not preventDefault", so the - * textarea keeps its own line break. Everything Enter could otherwise mean — - * accepting an open mention, accepting an open slash command — is claimed by - * earlier branches of the component's `onKeyDown`, which return before this - * resolver is consulted. + * The taxonomy is three-valued because the bindings are rebindable: `newline` + * is the action the user bound to breaking the line, `ignore` is the absence + * of a binding, and the component reads `ignore` as "do not preventDefault", so + * the textarea keeps its own behaviour for that key. Everything Enter could + * otherwise mean — accepting an open mention, accepting an open slash command + * — is claimed by earlier branches of the component's `onKeyDown`, which return + * before this resolver is consulted. * - * These tests pin the precedence the resolver documents: any modifier first, - * then an open IME composition, then the submit gate. The IME case is the one - * that is easy to regress silently and expensive when it does — a Chinese or - * Japanese candidate window is confirmed with Enter, and submitting on that - * keystroke would send a half-typed word. + * These tests pin the precedence the resolver documents: the composition + * first, then the configured bindings, then the submit gate. The IME case is + * the one that is easy to regress silently and expensive when it does — a + * Chinese or Japanese candidate window is confirmed with Enter, and + * submitting on that keystroke would send a half-typed word. */ -describe("resolveWebuiComposerEnterAction", () => { - const enter = { - shiftKey: false, - altKey: false, +describe("resolveWebuiComposerShortcutAction", () => { + const key = (overrides: Partial = {}): ShortcutKeyEvent => ({ + key: "Enter", + code: "Enter", ctrlKey: false, metaKey: false, - isComposing: false, - submitBlocked: false, - }; + altKey: false, + shiftKey: false, + ...overrides, + }); + const resolve = ( + event: ShortcutKeyEvent, + overrides: { readonly isComposing?: boolean; readonly submitBlocked?: boolean; readonly bindings?: ShortcutBindings } = {}, + ): string => + resolveWebuiComposerShortcutAction({ + event, + isComposing: overrides.isComposing ?? false, + submitBlocked: overrides.submitBlocked ?? false, + bindings: overrides.bindings ?? DEFAULT_SHORTCUT_BINDINGS, + }); it("submits on a bare Enter when the draft can be sent", () => { - expect(resolveWebuiComposerEnterAction(enter)).toBe("submit"); + expect(resolve(key())).toBe("submit"); + }); + + it("breaks the line on the newline binding", () => { + expect(resolve(key({ shiftKey: true }))).toBe("newline"); }); // Each modifier is checked on its own: a combined assertion would pass even - // if the implementation tested only one of them. - for (const modifier of ["shiftKey", "altKey", "ctrlKey", "metaKey"] as const) { - it(`keeps the newline for ${modifier}`, () => { - expect( - resolveWebuiComposerEnterAction({ ...enter, [modifier]: true }), - ).toBe("newline"); + // if the implementation tested only one of them. None of them is a binding + // under the default configuration, so the textarea keeps its own behaviour. + for (const modifier of ["altKey", "ctrlKey", "metaKey"] as const) { + it(`leaves ${modifier} + Enter to the browser`, () => { + expect(resolve(key({ [modifier]: true }))).toBe("ignore"); }); } - it("keeps the newline while an IME candidate window is open", () => { + it("leaves Enter alone while an IME candidate window is open", () => { // Submitting here would discard the candidate the user is still choosing. - expect(resolveWebuiComposerEnterAction({ ...enter, isComposing: true })).toBe( - "newline", - ); + expect(resolve(key(), { isComposing: true })).toBe("ignore"); }); it("does not submit when the send button is blocked", () => { // The component feeds its `submitBlocked` — the button's own `disabled` // condition — in, so the keyboard cannot open a path the button refuses. - expect( - resolveWebuiComposerEnterAction({ ...enter, submitBlocked: true }), - ).toBe("newline"); + expect(resolve(key(), { submitBlocked: true })).toBe("ignore"); }); - it("prefers composition over the submit gate and vice versa, both losing to modifiers", () => { - // Modifier is the outermost guard: Shift+Enter stays a newline even when - // a send is perfectly available. - expect( - resolveWebuiComposerEnterAction({ - ...enter, - shiftKey: true, - isComposing: false, - submitBlocked: false, - }), - ).toBe("newline"); + it("prefers composition over the submit gate and vice versa, both losing to a binding", () => { + // A binding is the outermost guard after the composition: the newline + // binding stands even when a send is perfectly available. + expect(resolve(key({ shiftKey: true }), { submitBlocked: false })).toBe("newline"); // With no modifier, composing still wins over an otherwise sendable draft. - expect( - resolveWebuiComposerEnterAction({ - ...enter, - shiftKey: false, - isComposing: true, - submitBlocked: false, - }), - ).toBe("newline"); + expect(resolve(key(), { isComposing: true, submitBlocked: false })).toBe("ignore"); }); - it("only submits when no modifier is held, nothing is composing, and the gate is open", () => { - expect( - resolveWebuiComposerEnterAction({ - shiftKey: false, - altKey: false, - ctrlKey: false, - metaKey: false, - isComposing: false, - submitBlocked: false, - }), - ).toBe("submit"); + it("follows the configured binding instead of the built-in one", () => { + const rebound: ShortcutBindings = { ...DEFAULT_SHORTCUT_BINDINGS, "composer.submit": "Control+Enter" }; + expect(resolve(key(), { bindings: rebound })).toBe("ignore"); + expect(resolve(key({ ctrlKey: true }), { bindings: rebound })).toBe("submit"); + }); + + it("follows a newline binding the browser does not break the line on", () => { + const rebound: ShortcutBindings = { ...DEFAULT_SHORTCUT_BINDINGS, "composer.newline": "Control+J" }; + expect(resolve(key({ key: "j", code: "KeyJ", ctrlKey: true }), { bindings: rebound })).toBe("newline"); }); }); diff --git a/packages/webui/test/unit/settings-modal.test.tsx b/packages/webui/test/unit/settings-modal.test.tsx index 19c1ed7cc..acbd4d566 100644 --- a/packages/webui/test/unit/settings-modal.test.tsx +++ b/packages/webui/test/unit/settings-modal.test.tsx @@ -136,13 +136,14 @@ it("leaves the implemented tabs clickable while the unimplemented tabs stay disa // A fully implemented panel must not carry the `disabled` gate the // not-yet-built panels still need. `account` (email row, sign-out button, // sign-out error region), `coding` (workspace review), `worktree` (parallel - // experiment branches) and `custom-instructions` (profile-wide AGENTS.md - // editor) are all built, so all four sit in the ungated group. The rest have - // no content behind them and clicking one would land on an empty pane. - for (const key of ["account", "coding", "worktree", "custom-instructions"]) { + // experiment branches), `custom-instructions` (profile-wide AGENTS.md + // editor) and `shortcuts` (the rebindable composer bindings) are all + // built, so all five sit in the ungated group. The rest have no content + // behind them and clicking one would land on an empty pane. + for (const key of ["account", "coding", "worktree", "custom-instructions", "shortcuts"]) { expect(DESKTOP_SETTINGS_TABS.find((tab) => tab.key === key)?.disabled).toBeUndefined(); } - for (const key of ["voice", "shortcuts", "connection"]) { + for (const key of ["voice", "connection"]) { expect(DESKTOP_SETTINGS_TABS.find((tab) => tab.key === key)?.disabled).toBe(true); } }); diff --git a/packages/webui/test/unit/settings-shortcuts.test.ts b/packages/webui/test/unit/settings-shortcuts.test.ts new file mode 100644 index 000000000..96202ded5 --- /dev/null +++ b/packages/webui/test/unit/settings-shortcuts.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SHORTCUT_BINDINGS, + findShortcutConflict, + normalizeShortcut, + parseShortcut, + parseShortcutBindings, + serializeShortcutBindings, + shortcutBaseKey, + shortcutDisplayLabel, + shortcutMatches, + type ShortcutKeyEvent, +} from "../../src/client/projection/common/shortcuts.js"; +import { + SHORTCUT_SETTINGS_ACTIONS, + shortcutActionTitle, + shortcutSettingsRows, +} from "../../src/client/features/settings/projection/settings-shortcuts.js"; + +/** A key press, with only what the notation reads. */ +const press = (overrides: Partial & { readonly key: string }): ShortcutKeyEvent => ({ + code: undefined, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + ...overrides, +}); + +describe("shortcut notation", () => { + it("names a bare key by the key itself", () => { + expect(parseShortcut(press({ key: "Enter", code: "Enter" }))).toBe("Enter"); + expect(parseShortcut(press({ key: "F5", code: "F5" }))).toBe("F5"); + expect(parseShortcut(press({ key: " ", code: "Space" }))).toBe("Space"); + }); + + it("names a modified key by its modifiers and its base key", () => { + expect(parseShortcut(press({ key: "Enter", code: "Enter", shiftKey: true }))).toBe("Shift+Enter"); + expect(parseShortcut(press({ key: "Enter", code: "Enter", ctrlKey: true }))).toBe("Control+Enter"); + expect(parseShortcut(press({ key: "Enter", code: "Enter", metaKey: true }))).toBe("Meta+Enter"); + expect(parseShortcut(press({ key: "Enter", code: "Enter", altKey: true }))).toBe("Alt+Enter"); + expect(parseShortcut(press({ key: "Enter", code: "Enter", ctrlKey: true, shiftKey: true }))).toBe("Control+Shift+Enter"); + }); + + it("keeps a letter and the same letter with shift apart", () => { + expect(parseShortcut(press({ key: "a", code: "KeyA" }))).toBe("A"); + expect(parseShortcut(press({ key: "A", code: "KeyA", shiftKey: true }))).toBe("Shift+A"); + }); + + it("names no binding for a modifier pressed on its own", () => { + // A binding is a combination. Capturing `Shift` alone would leave the user + // with a shortcut that fires whenever they hold a key down. + expect(parseShortcut(press({ key: "Shift", code: "ShiftLeft", shiftKey: true }))).toBeUndefined(); + expect(parseShortcut(press({ key: "Control", code: "ControlLeft", ctrlKey: true }))).toBeUndefined(); + expect(parseShortcut(press({ key: "Alt", code: "AltLeft", altKey: true }))).toBeUndefined(); + expect(parseShortcut(press({ key: "Meta", code: "MetaLeft", metaKey: true }))).toBeUndefined(); + }); + + it("reads a written binding back the way the page and the composer agree on it", () => { + expect(normalizeShortcut("ctrl+enter")).toBe("Control+Enter"); + expect(normalizeShortcut("Shift + Enter")).toBe("Shift+Enter"); + expect(normalizeShortcut("cmd+enter")).toBe("Meta+Enter"); + expect(normalizeShortcut("Enter")).toBe("Enter"); + }); + + it("rejects a written binding that cannot be one", () => { + expect(normalizeShortcut("")).toBeUndefined(); + expect(normalizeShortcut("Shift")).toBeUndefined(); + expect(normalizeShortcut("Hyper+Enter")).toBeUndefined(); + }); +}); + +describe("shortcut matching", () => { + it("matches the exact combination and nothing else", () => { + const enter = press({ key: "Enter", code: "Enter" }); + expect(shortcutMatches("Enter", enter)).toBe(true); + expect(shortcutMatches("Control+Enter", enter)).toBe(false); + expect(shortcutMatches("Shift+Enter", press({ key: "Enter", code: "Enter", shiftKey: true }))).toBe(true); + expect(shortcutMatches("Enter", press({ key: "Enter", code: "Enter", shiftKey: true }))).toBe(false); + }); + + it("matches nothing against a binding that names nothing", () => { + expect(shortcutMatches("", press({ key: "Enter", code: "Enter" }))).toBe(false); + }); + + it("answers which key a binding is about", () => { + expect(shortcutBaseKey("Control+Enter")).toBe("Enter"); + expect(shortcutBaseKey("Control+J")).toBe("J"); + expect(shortcutBaseKey("")).toBeUndefined(); + }); +}); + +describe("shortcut conflicts", () => { + it("reports the action a binding would collide with", () => { + expect(findShortcutConflict(DEFAULT_SHORTCUT_BINDINGS, "composer.newline", "Enter")).toBe("composer.submit"); + expect(findShortcutConflict(DEFAULT_SHORTCUT_BINDINGS, "composer.submit", "Shift+Enter")).toBe("composer.newline"); + }); + + it("does not report a collision with the action's own binding", () => { + expect(findShortcutConflict(DEFAULT_SHORTCUT_BINDINGS, "composer.submit", "Enter")).toBeUndefined(); + }); + + it("does not report a collision with a combination nothing is bound to", () => { + expect(findShortcutConflict(DEFAULT_SHORTCUT_BINDINGS, "composer.submit", "Control+Enter")).toBeUndefined(); + }); + + it("does not report a collision for a binding that cannot be one", () => { + expect(findShortcutConflict(DEFAULT_SHORTCUT_BINDINGS, "composer.submit", "Shift")).toBeUndefined(); + }); +}); + +describe("stored shortcut bindings", () => { + it("reads the built-in bindings when nothing is stored", () => { + expect(parseShortcutBindings(null)).toEqual(DEFAULT_SHORTCUT_BINDINGS); + expect(parseShortcutBindings(undefined)).toEqual(DEFAULT_SHORTCUT_BINDINGS); + }); + + it("falls back to the built-in bindings when the stored value cannot be read", () => { + expect(parseShortcutBindings("not json")).toEqual(DEFAULT_SHORTCUT_BINDINGS); + expect(parseShortcutBindings("[]")).toEqual(DEFAULT_SHORTCUT_BINDINGS); + expect(parseShortcutBindings('"a string"')).toEqual(DEFAULT_SHORTCUT_BINDINGS); + }); + + it("keeps the default of an action whose stored binding cannot be one", () => { + // A corrupt preference must not leave the composer without a way to send. + expect(parseShortcutBindings('{"composer.submit":"Shift"}')).toEqual(DEFAULT_SHORTCUT_BINDINGS); + expect(parseShortcutBindings('{"composer.submit":42}')).toEqual(DEFAULT_SHORTCUT_BINDINGS); + }); + + it("reads the stored binding of an action and leaves the others alone", () => { + expect(parseShortcutBindings('{"composer.submit":"Control+Enter"}')).toEqual({ + "composer.submit": "Control+Enter", + "composer.newline": "Shift+Enter", + "composer.stop": "", + }); + }); + + it("ignores an action it does not know", () => { + expect(parseShortcutBindings('{"composer.teleport":"Control+Enter"}')).toEqual(DEFAULT_SHORTCUT_BINDINGS); + }); + + it("writes the bindings back in their canonical form", () => { + expect(serializeShortcutBindings({ ...DEFAULT_SHORTCUT_BINDINGS, "composer.submit": "ctrl+enter" })).toBe( + '{"composer.submit":"Control+Enter","composer.newline":"Shift+Enter","composer.stop":""}', + ); + expect(parseShortcutBindings(serializeShortcutBindings(DEFAULT_SHORTCUT_BINDINGS))).toEqual(DEFAULT_SHORTCUT_BINDINGS); + }); +}); + +describe("how a binding is shown", () => { + it("spells the modifiers the way the platform names them", () => { + expect(shortcutDisplayLabel("Enter")).toBe("Enter"); + expect(shortcutDisplayLabel("Control+Enter")).toBe("Ctrl + Enter"); + expect(shortcutDisplayLabel("Shift+Enter")).toBe("Shift + Enter"); + expect(shortcutDisplayLabel("Meta+Enter")).toBe("Cmd + Enter"); + expect(shortcutDisplayLabel("Alt+Enter")).toBe("Alt + Enter"); + }); + + it("says so when a binding names nothing", () => { + expect(shortcutDisplayLabel("")).toBe("未设置"); + }); +}); + +describe("the shortcuts page rows", () => { + it("lists the two composer actions and the action that has no binding", () => { + const rows = shortcutSettingsRows(DEFAULT_SHORTCUT_BINDINGS); + expect(rows.map((row) => row.actionId)).toEqual(["composer.submit", "composer.newline", "composer.stop"]); + expect(rows.map((row) => row.title)).toEqual(["发送消息", "换行", "停止生成"]); + expect(rows.map((row) => row.editable)).toEqual([true, true, false]); + }); + + it("shows the current binding of each action", () => { + const rows = shortcutSettingsRows({ ...DEFAULT_SHORTCUT_BINDINGS, "composer.submit": "Control+Enter" }); + expect(rows[0]?.label).toBe("Ctrl + Enter"); + expect(rows[1]?.label).toBe("Shift + Enter"); + expect(rows[2]?.label).toBe("未设置"); + }); + + it("says that stopping a generation is a button and nothing else", () => { + // `Escape` is already spoken for by the menus it closes, so the row says so + // rather than offering a binding that would fight them. + expect(SHORTCUT_SETTINGS_ACTIONS[2]?.note).toBe("仅按钮,无快捷键"); + }); + + it("names the action a collided binding belongs to", () => { + expect(shortcutActionTitle("composer.submit")).toBe("发送消息"); + expect(shortcutActionTitle("composer.newline")).toBe("换行"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 7f3983478..40e0b2561 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3571,6 +3571,7 @@ "packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md", "packages/webui/docs/adr/0023-webui-test-coverage-follows-user-workflows-and-evidence-levels.md", "packages/webui/docs/adr/0024-the-client-draws-the-workspace-boundary-for-a-diff-body.md", + "packages/webui/docs/adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md", "packages/webui/docs/adr/observation-architecture.md", "packages/webui/docs/agents/domain.md", "packages/webui/docs/agents/issue-tracker.md", @@ -3885,6 +3886,7 @@ "packages/webui/src/client/features/settings/application/settings-workflows.ts", "packages/webui/src/client/features/settings/contracts/memory-handoff.ts", "packages/webui/src/client/features/settings/contracts/settings-port.ts", + "packages/webui/src/client/features/settings/projection/settings-shortcuts.ts", "packages/webui/src/client/features/settings/projection/settings-view.ts", "packages/webui/src/client/features/settings/projection/usage-plan.ts", "packages/webui/src/client/features/settings/projection/usage-report.ts", @@ -3895,6 +3897,7 @@ "packages/webui/src/client/features/settings/ui/PersonalizationSettings.tsx", "packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx", "packages/webui/src/client/features/settings/ui/SettingsModal.tsx", + "packages/webui/src/client/features/settings/ui/ShortcutsSettings.tsx", "packages/webui/src/client/features/settings/ui/UsageHeatmap.tsx", "packages/webui/src/client/features/settings/ui/UsageModelSettings.tsx", "packages/webui/src/client/features/settings/ui/UsageResetCards.tsx", @@ -4030,6 +4033,7 @@ "packages/webui/src/client/projection/common/overlay-geometry.ts", "packages/webui/src/client/projection/common/paths.ts", "packages/webui/src/client/projection/common/router.ts", + "packages/webui/src/client/projection/common/shortcuts.ts", "packages/webui/src/client/shell/ArchonPage.tsx", "packages/webui/src/client/shell/ArchonShell.tsx", "packages/webui/src/client/shell/ConnectionStatus.tsx", @@ -4231,6 +4235,7 @@ "packages/webui/test/browser/review-consumers.spec.mjs", "packages/webui/test/browser/server.mjs", "packages/webui/test/browser/settings-account-tab.spec.mjs", + "packages/webui/test/browser/settings-shortcuts.spec.mjs", "packages/webui/test/browser/settings-theme.spec.mjs", "packages/webui/test/browser/support/native-browser-host.ts", "packages/webui/test/browser/terminal-cross-page.spec.mjs", @@ -4378,6 +4383,7 @@ "packages/webui/test/unit/settings-layout.test.tsx", "packages/webui/test/unit/settings-modal.test.tsx", "packages/webui/test/unit/settings-mutation-ordering.test.ts", + "packages/webui/test/unit/settings-shortcuts.test.ts", "packages/webui/test/unit/settings-workflows.test.ts", "packages/webui/test/unit/shell-surface.test.ts", "packages/webui/test/unit/slash-classification.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index f75003d21..b338d53bd 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -343,6 +343,7 @@ "packages/webui/test/unit/session-transfer-route.test.ts", "packages/webui/test/unit/session-transfer.test.ts", "packages/webui/test/unit/settings-layout.test.tsx", + "packages/webui/test/unit/settings-shortcuts.test.ts", "packages/webui/test/unit/settings-modal.test.tsx", "packages/webui/test/unit/settings-workflows.test.ts", "packages/webui/test/unit/shell-surface.test.ts",