From 2a1f2455cc87f9691de79c242d71f2335b344a59 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:50:43 +0800 Subject: [PATCH 1/4] feat(webui): make Ctrl+, true, and let it be rebound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap P 区「快捷键管理」. The ledger records this row as "the tab is disabled", which undersells it: there is no shortcut system at all. The user menu has always labelled its settings row `Ctrl+,`, and nothing anywhere in the client honoured that combination. The three document-level keydown listeners that do exist belong to the context menu, the goal banner and the user-menu popover, and each handles only Escape while its own popover is open. So the first thing this has to survive is being the thing the label already promised. Three commands are registered, and only three, because they are the only ones with a real handler behind them: openSettings (Ctrl+,), toggleSidebar (Ctrl+B, which toggles the rail that already exists) and focusComposer (Ctrl+L). A registry full of rows nobody wired would be the same defect wearing a different hat, so the tests assert the other direction too — every command id must appear as a branch in the shell. `shortcut-state.ts` is pure and DOM-free, which is what makes the interesting decisions testable without jsdom: parsing and canonicalising a binding, matching an event, resolving overrides over defaults, refusing a combination another command already holds. Bindings are stored platform-neutrally with Ctrl as the primary modifier and translated for display on macOS; storing the display string would mean a binding written on one platform does not parse on the other. The settings page is presentational like the E-area panels, so its rows are asserted directly, and the click paths that a static render cannot reach are asserted against the source — the same shape `rail-pin-affordance.test.ts` already uses in this repo. Two things are deliberately refused when rebinding, and the tests say why: a lone modifier is a press in progress, and Escape is the cancel gesture. Escape closes every popover in this shell, so no command defaults to it either. The settings dialog is mounted by UserMenu, which only exists while the rail is expanded. Firing `Ctrl+,` into nothing is worse than not having the shortcut, so the handler opens the rail first and delivers the signal once the menu has mounted. Validation - pnpm check:source exit 0 (4755 files) - pnpm typecheck:webui-full exit 0 - pnpm build:webui exit 0 - run-vitest-suite.mjs webui 10 failed | 1890 passed | 4 skipped - playwright test test/webui-browser/ 96 passed The 10 failures are the unchanged pre-existing Windows baseline, confirmed on a clean tree of this same base by stashing: 7 webui-boundary-check path assertions, 2 webui-design-tokens path assertions, 1 webui-service shutdown timeout. This branch adds none. Two existing tests moved their lists rather than losing their teeth: settings-modal.test.tsx and settings-account-tab.spec.mjs both asserted that `shortcuts` sat in the disabled group. Both now assert it in the implemented group while still asserting `voice` and `connection` are disabled — which is what keeps the check from being vacuous. Test evidence 48 new tests in shortcut-state.test.ts, registered in test/vitest-suites.json. Red before the implementation: 11 of them failed against an empty stub — the component was not exported, the tab was not routed, the tab was still disabled, and the shell had no key handler. Negative injection: 24 mutations across the registry, the page and the wiring, 0 survivors, implementation restored byte-for-byte. The first run found four problems, two of them real: - Removing `"shortcuts"` from the empty-pane guard left the suite green. The routing assertion and the guard are independent: without the tab in the guard, both the page and the empty pane render at once. There is now an assertion scoped to the guard, alongside the one for the E-area tabs. - Replacing the handler's override read with an empty list left the suite green, because the only wiring assertion was `resolveWebuiShortcutBindings` appearing somewhere in the file. This is the seam between the two halves of the feature: the page would show saved keys while the shell kept firing the defaults. Both halves are now asserted. The other two first-run entries were my own harness writing `{(true ? (`, which is not valid JS — the run crashed rather than reporting a result, and the harness counts a crash as inconclusive rather than killed, which is the conservative call. The suite also caught a real bug while the capture path was being written: `F2` was being case-folded to `f2`, and matching compares named keys verbatim, so the binding would have been stored in a form that could never fire. Scope: this is the first of the three rows in P 区. 语音输入 has no implementation anywhere in the client yet — no getUserMedia, no SpeechRecognition — and 多语言 is a full extraction: 8604 CJK characters across 77 of 109 client files, with `mavis-locale` currently read by a `useState` that has no setter. Neither belongs in this change. --- .../src/client/components/SettingsModal.tsx | 143 +++++- .../webui/src/client/components/UserMenu.tsx | 17 + .../components/WebuiClientFoundationApp.tsx | 74 ++++ .../src/client/projection/shortcut-state.ts | 274 ++++++++++++ packages/webui/src/client/styles/shell.css | 15 + .../webui/test/unit/settings-modal.test.tsx | 13 +- .../webui/test/unit/shortcut-state.test.ts | 418 ++++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 1 + .../settings-account-tab.spec.mjs | 8 +- 10 files changed, 953 insertions(+), 12 deletions(-) create mode 100644 packages/webui/src/client/projection/shortcut-state.ts create mode 100644 packages/webui/test/unit/shortcut-state.test.ts diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx index b82e7fe5..cc199ced 100644 --- a/packages/webui/src/client/components/SettingsModal.tsx +++ b/packages/webui/src/client/components/SettingsModal.tsx @@ -19,6 +19,17 @@ import { type WebuiWorktreeSourceSession, type WebuiWorktreeWorkspace, } from "../projection/worktree-state.js"; +import { + WEBUI_SHORTCUT_COMMANDS, + WEBUI_SHORTCUT_OVERRIDES_KEY, + findWebuiShortcutConflict, + formatWebuiShortcut, + parseWebuiShortcutOverrides, + resetWebuiShortcutOverrides, + resolveWebuiShortcutBindings, + webuiShortcutFromEvent, + type WebuiShortcutOverride, +} from "../projection/shortcut-state.js"; import { ToggleSwitch as Switch } from "./ToggleSwitch.js"; import { UsageModelSettings } from "./settings/UsageModelSettings.js"; import { PersonalizationSettings, type MemoryHandoff } from "./settings/PersonalizationSettings.js"; @@ -26,7 +37,7 @@ import { PersonalizationSettings, type MemoryHandoff } from "./settings/Personal export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instructions" | "usage" | "connection" | "account" | "coding" | "worktree" | "archived"; 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" }, { key: "usage", group: "management", label: "用量与模型", icon: "chart" }, { key: "connection", group: "management", label: "连接", icon: "link", disabled: true }, { key: "account", group: "management", label: "账户", icon: "user" }, { key: "coding", group: "coding", label: "代码审查", icon: "coding" }, { key: "worktree", group: "coding", label: "工作树", icon: "worktree" }, { key: "archived", group: "archived", label: "已归档任务", icon: "archived" }, ]; export const SETTINGS_GROUPS = [{ key: "preferences", label: "偏好" }, { key: "management", label: "管理" }, { key: "coding", label: "编码" }, { key: "archived", label: "归档" }] as const; @@ -208,7 +219,7 @@ refreshModels, 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; const changeModel = async (value: string) => { const model = models.find((candidate) => `${candidate.providerId}/${candidate.modelId}/${candidate.variant ?? ""}` === value); if (!model || !selectModel) return; await selectModel({ providerId: model.providerId, modelId: model.modelId, ...(model.variant ? { variant: model.variant } : {}), ...(sessionId ? { sessionId } : {}) }); }; const handleSignOut = async () => { if (!signOut) return; try { setSignOutError(undefined); await signOut(); onClose(); } catch (error) { setSignOutError(error instanceof Error ? error.message : String(error)); } }; const handleDeleteAllArchived = async () => { if (!deleteSession || !archived.length || !window.confirm("确定删除全部已归档任务吗?此操作无法撤销。")) return; const ids = archived.map((session) => session.sessionId); try { await Promise.all(ids.map((id) => deleteSession({ id }))); setArchived([]); } 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 === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "custom-instructions" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

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

{label}

{active === "archived" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active === "shortcuts" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "shortcuts" && active !== "custom-instructions" ?
: 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 { @@ -440,6 +451,134 @@ export function WebuiReviewWorkspacePicker({ workspaces, selected, loading, erro ; } +/* Roadmap P 区「快捷键管理」. + * + * Presentational like the review and worktree panels, so the rows it renders + * are assertable without a DOM. Which commands exist and how a binding is + * parsed live in `shortcut-state.ts`; nothing here decides anything. */ +export function WebuiShortcutSettings({ overrides, platform, editing, conflict, onStartEdit, onReset, onCancelEdit }: { + readonly overrides: readonly WebuiShortcutOverride[]; + readonly platform?: string; + readonly editing?: string; + readonly conflict?: { readonly commandId: string; readonly label: string } | undefined; + readonly onStartEdit?: (commandId: string) => void; + readonly onReset?: (commandId: string) => void; + readonly onCancelEdit?: () => void; +}): ReactElement { + const bindings = resolveWebuiShortcutBindings(overrides); + const overridden = new Set(overrides.map((override) => override.id)); + const groups = [...new Set(WEBUI_SHORTCUT_COMMANDS.map((command) => command.group))]; + return
+ {groups.map((group) =>
+

{group}

+ +
)} + {conflict ? ( +

+ 这个组合键已经被「{conflict.label}」占用了,换一个。 +

+ ) : null} + {editing ? ( + + ) : null} +
; +} + +/** Reads overrides once on mount and owns the capture gesture while a row is + * being rebound. The shell's global handler reads the same storage key. */ +function SettingsShortcutsPage(): ReactElement { + const [overrides, setOverrides] = useState([]); + const [editing, setEditing] = useState(undefined); + const [conflict, setConflict] = useState<{ readonly commandId: string; readonly label: string } | undefined>(undefined); + const [platform, setPlatform] = useState(""); + + useEffect(() => { + setOverrides(parseWebuiShortcutOverrides(localStorage.getItem(WEBUI_SHORTCUT_OVERRIDES_KEY))); + setPlatform(typeof navigator === "undefined" ? "" : navigator.platform || ""); + }, []); + + const write = useCallback((next: readonly WebuiShortcutOverride[]) => { + setOverrides(next); + localStorage.setItem(WEBUI_SHORTCUT_OVERRIDES_KEY, JSON.stringify(next)); + }, []); + + useEffect(() => { + if (!editing) return undefined; + /* Capture phase, so the row wins over the shell's own global dispatcher + * for the duration of the gesture rather than after it. */ + const onKeyDown = (event: KeyboardEvent): void => { + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + setEditing(undefined); + setConflict(undefined); + return; + } + const binding = webuiShortcutFromEvent(event); + if (!binding) return; + const clash = findWebuiShortcutConflict(binding, editing, resolveWebuiShortcutBindings(overrides)); + if (clash) { + setConflict({ + commandId: clash, + label: WEBUI_SHORTCUT_COMMANDS.find((command) => command.id === clash)?.label ?? clash, + }); + return; + } + write([...resetWebuiShortcutOverrides(overrides, editing), { id: editing, binding }]); + setEditing(undefined); + setConflict(undefined); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [editing, overrides, write]); + + return { setEditing(commandId); setConflict(undefined); }} + onReset={(commandId) => { write(resetWebuiShortcutOverrides(overrides, commandId)); setConflict(undefined); }} + onCancelEdit={() => { setEditing(undefined); setConflict(undefined); }} + />; +} + export function WebuiReviewPanel({ state, visible, filtering, loading, note, empty, workspaceDir, onQueryChange, onClearFilters, onToggleFile, onOpenFileLine }: { readonly state: WebuiReviewState; readonly visible?: readonly WebuiReviewFile[]; diff --git a/packages/webui/src/client/components/UserMenu.tsx b/packages/webui/src/client/components/UserMenu.tsx index 08580afb..19cb7108 100644 --- a/packages/webui/src/client/components/UserMenu.tsx +++ b/packages/webui/src/client/components/UserMenu.tsx @@ -35,6 +35,11 @@ type WebuiUserMenuCapabilities = WebuiSettingsModalCapabilities; interface UserMenuProps { readonly collapsed: boolean; + /* Bumped by the shell's global shortcut handler. The settings dialog is + * mounted from here, so a `Ctrl+,` press has to reach this component + * somehow; a counter survives the component not re-rendering and is cheaper + * to wire than a new callback. */ + readonly openSettingsSignal?: number; readonly hostLabel?: string; readonly dataDir?: string; readonly version?: WebuiVersionInfo; @@ -728,6 +733,7 @@ export function UserMenu({ getSigninPanel, claimSignin, onCreateMemorySession, + openSettingsSignal, }: UserMenuProps): ReactElement { // Bound once per transport, not per render: the login dialog's effects // key on these callbacks, and a fresh binding every render would restart @@ -751,6 +757,17 @@ export function UserMenu({ }, [onCreateMemorySession]); const [open, setOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + + /* The global `openSettings` shortcut arrives as a counter rather than a + * callback so that pressing the combination twice while the dialog is + * already open re-focuses it instead of being swallowed as a duplicate. */ + const lastSettingsSignal = useRef(0); + useEffect(() => { + if (openSettingsSignal === undefined || openSettingsSignal === lastSettingsSignal.current) return; + lastSettingsSignal.current = openSettingsSignal; + setOpen(false); + setSettingsOpen(true); + }, [openSettingsSignal]); const [loginOpen, setLoginOpen] = useState(false); const [usageOpen, setUsageOpen] = useState(false); const [usage, setUsage] = useState({ status: "idle" }); diff --git a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx index 4ddf5d2f..f0453c9d 100644 --- a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx +++ b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx @@ -134,6 +134,12 @@ import { createSessionStreamRetry } from "../session-stream-retry.js"; import { ConnectionStatus } from "../ConnectionStatus.js"; import { deriveConversationUsageNotice } from "../projection/message-projection.js"; import { deriveRecentWorkspaceDirs } from "../projection/composer-state.js"; +import { + WEBUI_SHORTCUT_OVERRIDES_KEY, + matchesWebuiShortcut, + parseWebuiShortcutOverrides, + resolveWebuiShortcutBindings, +} from "../projection/shortcut-state.js"; /** * Hash helpers used by the shell. `main.tsx` also calls @@ -867,6 +873,73 @@ export function WebuiClientFoundationApp( ? teamModeChoices[selectedSessionId] ?? teamModeOff : teamModeOff; const [railCollapsed, setRailCollapsed] = useState(false); + + /* Roadmap P 区「快捷键管理」. + * + * The user menu has always labelled its settings row `Ctrl+,` and nothing + * ever handled that combination — the label was decoration. This is the one + * place that now makes it true. + * + * Every branch here corresponds to a row in `WEBUI_SHORTCUT_COMMANDS`. A + * command with no branch behind it would render in the settings page and do + * nothing, which is the defect this whole change removes, so the registry's + * tests assert both directions. */ + const [settingsSignal, setSettingsSignal] = useState(0); + const [pendingSettingsSignal, setPendingSettingsSignal] = useState(false); + useEffect(() => { + const runShortcut = (commandId: string): void => { + if (commandId === "toggleSidebar") { + setRailCollapsed((collapsed) => !collapsed); + return; + } + if (commandId === "openSettings") { + // The settings dialog is mounted by UserMenu, which only exists while + // the rail is expanded. Firing into nothing is the one outcome worse + // than a shortcut that does not exist, so the rail opens first and the + // signal lands once the menu has mounted. + if (railCollapsed) { + setRailCollapsed(false); + setPendingSettingsSignal(true); + return; + } + setSettingsSignal((signal) => signal + 1); + return; + } + if (commandId === "focusComposer") { + // Queried rather than passed down: the composer owns its own ref, and + // threading one through FoundationApp for a focus call would be a + // wider change than the shortcut earns. `name="content"` is the + // textarea's existing identity and is unique in the shell. + document.querySelector('textarea[name="content"]')?.focus(); + } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + const platform = typeof navigator === "undefined" ? undefined : navigator.platform || undefined; + for (const [commandId, binding] of resolveWebuiShortcutBindings( + parseWebuiShortcutOverrides(localStorage.getItem(WEBUI_SHORTCUT_OVERRIDES_KEY)), + )) { + if (!matchesWebuiShortcut(event, binding, platform)) continue; + // A binding the user set while typing must not eat the keystroke on + // its way to the textarea, or the character never lands. + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + if (!event.ctrlKey && !event.metaKey && !event.altKey) return; + } + event.preventDefault(); + runShortcut(commandId); + return; + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [railCollapsed]); + + useEffect(() => { + if (!pendingSettingsSignal || railCollapsed) return; + setPendingSettingsSignal(false); + setSettingsSignal((signal) => signal + 1); + }, [pendingSettingsSignal, railCollapsed]); // Rail session search. `railSearchOpen` mirrors whether the input is // showing; `railSearchQuery` is the live filter. The query filters only the // rail's rendered list — lookups such as the selected-session resolution and @@ -1269,6 +1342,7 @@ export function WebuiClientFoundationApp( version={runtimeVersion} sessionId={selectedSessionId} workspaceDir={selectedSession?.workspaceDir} + openSettingsSignal={settingsSignal} onOpenFileLine={(path, line) => { // The open-file command needs a concrete session and // workspace; the review page is only reachable from a diff --git a/packages/webui/src/client/projection/shortcut-state.ts b/packages/webui/src/client/projection/shortcut-state.ts new file mode 100644 index 00000000..60d26f47 --- /dev/null +++ b/packages/webui/src/client/projection/shortcut-state.ts @@ -0,0 +1,274 @@ +/* Roadmap P 区「快捷键管理」. + * + * There was no shortcut system to manage before this. The settings row in the + * user menu is labelled `Ctrl+,`, and no keydown listener in the client ever + * honoured it — the three document-level listeners that do exist belong to the + * context menu, the goal banner and the user-menu popover, and each handles + * only Escape while its own popover is open. + * + * Bindings are stored platform-neutrally with `Ctrl` as the primary modifier, + * and translated for display on macOS. Storing a display string instead would + * mean a binding written on a Mac does not parse on Windows and vice versa. + * + * Only commands that exist are registered. Escape and the arrow keys are + * deliberately absent: they are how every popover closes and every list + * navigates, and a user-rebindable Escape breaks the whole shell. The registry + * asserts that in its own tests. + */ + +export interface WebuiShortcutBinding { + readonly ctrl: boolean; + readonly meta: boolean; + readonly alt: boolean; + readonly shift: boolean; + /** Lower-cased for letters, verbatim for punctuation and named keys. */ + readonly key: string; +} + +/** The subset of KeyboardEvent this module reads. */ +export interface WebuiShortcutKeyEvent { + readonly ctrlKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; + readonly key: string; +} + +export interface WebuiShortcutCommand { + readonly id: string; + readonly label: string; + readonly group: string; + readonly defaultBinding: string; +} + +export interface WebuiShortcutOverride { + readonly id: string; + readonly binding: string; +} + +/** + * Every command here is dispatched by the global key handler in + * `WebuiClientFoundationApp`. A command with no handler behind it would render + * in the settings page and do nothing — which is the defect this whole change + * exists to remove. + */ +export const WEBUI_SHORTCUT_COMMANDS: readonly WebuiShortcutCommand[] = [ + { id: "openSettings", label: "打开设置", group: "应用", defaultBinding: "Ctrl+," }, + { id: "toggleSidebar", label: "显示或隐藏侧栏", group: "应用", defaultBinding: "Ctrl+B" }, + { id: "focusComposer", label: "聚焦输入框", group: "会话", defaultBinding: "Ctrl+L" }, +]; + +const MODIFIER_ALIASES: ReadonlyMap = new Map([ + ["ctrl", "ctrl"], + ["control", "ctrl"], + ["⌃", "ctrl"], + ["meta", "meta"], + ["cmd", "meta"], + ["command", "meta"], + ["⌘", "meta"], + ["alt", "alt"], + ["option", "alt"], + ["⌥", "alt"], + ["shift", "shift"], + ["⇧", "shift"], +]); + +/** + * On a US layout the browser reports the shifted glyph for a shifted + * punctuation key: `Ctrl+Shift+,` arrives as `<`. Both spellings have to match + * the stored `,` or the binding silently stops firing as soon as Shift is held. + */ +const SHIFTED_GLYPHS: ReadonlyMap = new Map([ + ["<", ","], + [">", "."], + ["?", "/"], + [":", ";"], + ['"', "'"], + ["{", "["], + ["}", "]"], + ["|", "\\"], + ["_", "-"], + ["+", "="], + ["~", "`"], +]); + +export function parseWebuiShortcut(binding: string): WebuiShortcutBinding | undefined { + const parts = binding.split("+").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) return undefined; + + const parsed = { ctrl: false, meta: false, alt: false, shift: false, key: "" }; + for (const part of parts) { + const modifier = MODIFIER_ALIASES.get(part.toLowerCase()); + if (modifier) { + parsed[modifier] = true; + continue; + } + // Two non-modifier tokens means something like `Ctrl+A+B`, which is not a + // shortcut anyone can press. + if (parsed.key.length > 0) return undefined; + parsed.key = part.length === 1 ? part.toLowerCase() : part; + } + // A modifier with no key would fire on Ctrl+anything. + if (parsed.key.length === 0) return undefined; + return parsed; +} + +function platformUsesMeta(platform?: string): boolean { + return platform === "darwin"; +} + +export function formatWebuiShortcut(binding: string, platform?: string): string { + const parsed = parseWebuiShortcut(binding); + if (!parsed) return binding; + const mac = platformUsesMeta(platform); + const parts: string[] = []; + if (parsed.ctrl) parts.push(mac ? "⌘" : "Ctrl"); + if (parsed.alt) parts.push(mac ? "⌥" : "Alt"); + if (parsed.shift) parts.push(mac ? "⇧" : "Shift"); + if (parsed.meta) parts.push(mac ? "⌘" : "Meta"); + parts.push(displayKey(parsed.key, mac)); + return parts.join("+"); +} + +function displayKey(key: string, mac: boolean): string { + if (/^[a-z]$/u.test(key)) return key.toUpperCase(); + if (key === "Enter") return mac ? "↩" : "Enter"; + return key; +} + +/** Comparison key that ignores how the combination was written or displayed. */ +function canonicalWebuiShortcut(binding: string): string | undefined { + const parsed = parseWebuiShortcut(binding); + if (!parsed) return undefined; + return [ + parsed.ctrl ? "ctrl" : "", + parsed.meta ? "meta" : "", + parsed.alt ? "alt" : "", + parsed.shift ? "shift" : "", + parsed.key, + ].join("+"); +} + +export function matchesWebuiShortcut(event: WebuiShortcutKeyEvent, binding: string, platform?: string): boolean { + const parsed = parseWebuiShortcut(binding); + if (!parsed) return false; + + // `Ctrl` in a stored binding means the platform's primary modifier: ⌘ on + // macOS, Ctrl everywhere else. Matching the raw ctrlKey unconditionally would + // make a Windows-key chord fire a Ctrl binding. + const ctrlHeld = platformUsesMeta(platform) ? event.metaKey : event.ctrlKey; + if (parsed.ctrl !== ctrlHeld) return false; + if (parsed.meta !== event.metaKey) return false; + if (parsed.alt !== event.altKey) return false; + if (parsed.shift !== event.shiftKey) return false; + + const reported = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const unshifted = parsed.shift ? SHIFTED_GLYPHS.get(reported) ?? reported : reported; + return unshifted === parsed.key; +} + +export function resolveWebuiShortcutBindings( + overrides: readonly WebuiShortcutOverride[], +): ReadonlyMap { + const resolved = new Map(); + for (const command of WEBUI_SHORTCUT_COMMANDS) resolved.set(command.id, command.defaultBinding); + // An override that names no known command, or that cannot be parsed, is + // dropped rather than allowed to break the registry: this data is read back + // out of localStorage, which the user can edit by hand. + for (const override of overrides) { + if (!resolved.has(override.id)) continue; + if (!parseWebuiShortcut(override.binding)) continue; + resolved.set(override.id, override.binding); + } + return resolved; +} + +export function findWebuiShortcutConflict( + candidate: string, + commandId: string, + bindings: ReadonlyMap, +): string | undefined { + const canonical = canonicalWebuiShortcut(candidate); + if (!canonical) return undefined; + for (const [id, binding] of bindings) { + if (id === commandId) continue; + if (canonicalWebuiShortcut(binding) === canonical) return id; + } + return undefined; +} + +export function resetWebuiShortcutOverrides( + overrides: readonly WebuiShortcutOverride[], + commandId: string, +): WebuiShortcutOverride[] { + return overrides.filter((override) => override.id !== commandId); +} + +/** + * Shared by the settings page that writes overrides and the shell's global + * handler that reads them, so the two cannot drift onto different keys. + */ +export const WEBUI_SHORTCUT_OVERRIDES_KEY = "webui-shortcut-overrides"; + +export function parseWebuiShortcutOverrides(raw: string | null | undefined): WebuiShortcutOverride[] { + if (!raw) return []; + try { + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) return []; + return value.filter( + (entry): entry is WebuiShortcutOverride => + typeof entry === "object" && + entry !== null && + typeof (entry as WebuiShortcutOverride).id === "string" && + typeof (entry as WebuiShortcutOverride).binding === "string", + ); + } catch { + return []; + } +} + +const MODIFIER_KEY_NAMES: ReadonlySet = new Set(["control", "shift", "alt", "meta", "altgraph"]); +const ESCAPE_KEY = "escape"; +const FUNCTION_KEY = /^f([1-9]|1[0-9]|2[0-4])$/u; + +/** + * Builds the binding string for a key the user just pressed while the rebind + * row is capturing. + * + * Three things are deliberately not bindings. A lone modifier is the user still + * reaching for the combination; Escape is the cancel gesture, and accepting it + * would make backing out of a rebind impossible; and a bare letter is refused + * because the capture row is modal — without a modifier it reads as ordinary + * typing. Function keys are the exception, since they never carry text. + * + * What is pressed is recorded literally: `Meta+K` stays `Meta+K` and + * `Ctrl+K` stays `Ctrl+K`. `matchesWebuiShortcut` is where the platform's + * primary modifier is decided, so doing it here as well would be a second + * place for the two to disagree. + */ +export function webuiShortcutFromEvent(event: WebuiShortcutKeyEvent): string | undefined { + // Only single characters are case-folded. `KeyboardEvent.key` reports `F2` + // and matching compares named keys verbatim, so folding it to `f2` would + // store a binding that can never fire. The checks below compare against a + // folded copy for the same reason: `Escape` is neither length 1 nor spelled + // lowercase. + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const folded = key.toLowerCase(); + if (MODIFIER_KEY_NAMES.has(folded) || folded === ESCAPE_KEY) return undefined; + if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && !FUNCTION_KEY.test(folded)) { + return undefined; + } + const binding = [ + event.ctrlKey ? "Ctrl" : "", + event.metaKey ? "Meta" : "", + event.altKey ? "Alt" : "", + event.shiftKey ? "Shift" : "", + key, + ] + .filter(Boolean) + .join("+"); + // Round-tripping through the parser is what keeps a recorded binding + // loadable: an unparseable one would be dropped on the next read and the + // user's choice would silently revert to the default. + return parseWebuiShortcut(binding) ? binding : undefined; +} diff --git a/packages/webui/src/client/styles/shell.css b/packages/webui/src/client/styles/shell.css index e7c873dd..ce75149a 100644 --- a/packages/webui/src/client/styles/shell.css +++ b/packages/webui/src/client/styles/shell.css @@ -299,6 +299,21 @@ .webui-review-error { margin: 0 0 var(--spacing_8); color: var(--text_label_danger_secondary_default); font-size: var(--size_12); } /* Worktree list (roadmap E 区 工作树隔离). */ + /* Rebindable command list (roadmap P zone: keyboard shortcut management). */ + .webui-settings-shortcut-group { margin: 0 0 var(--spacing_20); } + .webui-settings-shortcut-group-name { margin: 0 0 var(--spacing_8); color: var(--text_default_tertiary); font-size: var(--size_12); font-weight: 600; } + .webui-settings-shortcut-list { margin: 0; padding: 0; list-style: none; } + .webui-settings-shortcut-row { display: flex; align-items: center; gap: var(--spacing_8); padding: var(--spacing_8) 0; border-bottom: 0.5px solid var(--border_default); } + .webui-settings-shortcut-row:last-child { border-bottom: 0; } + .webui-settings-shortcut-label { flex: 1; color: var(--text_default_primary); font-size: var(--size_12); } + .webui-settings-shortcut-key { min-width: 96px; padding: var(--spacing_4) var(--spacing_8); border: 0.5px solid var(--border_default); border-radius: var(--spacing_4); background: var(--bg_interaction_secondary_default); color: var(--text_default_primary); font: inherit; font-size: var(--size_12); text-align: center; cursor: pointer; } + .webui-settings-shortcut-key:hover { background: var(--bg_interaction_secondary_hover); } + .webui-settings-shortcut-capture { min-width: 96px; color: var(--text_default_tertiary); font-size: var(--size_12); text-align: center; } + .webui-settings-shortcut-reset { border: 0; padding: 0; background: transparent; color: var(--text_default_tertiary); font: inherit; font-size: var(--size_12); cursor: pointer; } + .webui-settings-shortcut-reset:hover { color: var(--text_default_primary); } + .webui-settings-shortcut-cancel { margin-top: var(--spacing_8); border: 0; background: transparent; color: var(--text_default_tertiary); font: inherit; font-size: var(--size_12); cursor: pointer; } + .webui-settings-shortcut-conflict { margin: var(--spacing_8) 0 0; color: var(--text_label_danger_secondary_default); font-size: var(--size_12); } + .webui-worktree-section-title { margin: var(--spacing_20) 0 var(--spacing_8); color: var(--text_default_primary); font-size: var(--size_14); } .webui-worktree-branches { display: flex; flex-direction: column; gap: var(--spacing_8); margin: 0; padding: 0; list-style: none; } .webui-worktree-group { padding: 10px 12px; border: 0.5px solid var(--border_default); border-radius: 10px; } diff --git a/packages/webui/test/unit/settings-modal.test.tsx b/packages/webui/test/unit/settings-modal.test.tsx index 6051ebc7..2235f222 100644 --- a/packages/webui/test/unit/settings-modal.test.tsx +++ b/packages/webui/test/unit/settings-modal.test.tsx @@ -90,17 +90,18 @@ describe("desktop settings registry", () => { }); describe("account tab gating", () => { -it("leaves the implemented tabs clickable while the unimplemented tabs stay disabled", () => { + it("leaves the implemented tabs clickable while the unimplemented tabs stay disabled", () => { // 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` (rebindable command registry) 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/shortcut-state.test.ts b/packages/webui/test/unit/shortcut-state.test.ts new file mode 100644 index 00000000..72ae16c0 --- /dev/null +++ b/packages/webui/test/unit/shortcut-state.test.ts @@ -0,0 +1,418 @@ +/* Roadmap P 区「快捷键管理」. + * + * The gap in the ledger reads "快捷键管理(可视化改键): tab 禁用". That + * undersells it. There is no shortcut system at all: the UserMenu labels the + * settings row `Ctrl+,`, and no keydown listener anywhere in the client ever + * honours that combination — the three document-level listeners that exist + * belong to the context menu, the goal banner and the user-menu popover, and + * each of them only handles Escape while its own popover is open. So the first + * thing this registry has to survive is being the thing the label already + * promised. + * + * Everything here is a pure function over a command list and a set of + * overrides. The webui suite has no jsdom, so the module has to stay free of + * DOM access and the presentational half lives in `SettingsModal.tsx` as an + * exported component, exactly like `WebuiReviewPanel` from the E-area work. + */ + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { WebuiShortcutSettings } from "../../src/client/components/SettingsModal.js"; +import { + WEBUI_SHORTCUT_COMMANDS, + parseWebuiShortcut, + formatWebuiShortcut, + resolveWebuiShortcutBindings, + findWebuiShortcutConflict, + matchesWebuiShortcut, + webuiShortcutFromEvent, + resetWebuiShortcutOverrides, + parseWebuiShortcutOverrides, + type WebuiShortcutBinding, + type WebuiShortcutKeyEvent, + type WebuiShortcutOverride, +} from "../../src/client/projection/shortcut-state.js"; + +describe("parsing a binding", () => { + it("reads a modifier combination and a key", () => { + expect(parseWebuiShortcut("Ctrl+,")).toEqual({ ctrl: true, meta: false, alt: false, shift: false, key: "," }); + }); + + /* People type these in whatever order the muscle memory has. The stored + * form has to canonicalise or a user who rewrites the same shortcut gets a + * "different" one. */ + it("ignores the order the modifiers are written in", () => { + expect(parseWebuiShortcut("Shift+Ctrl+P")).toEqual(parseWebuiShortcut("Ctrl+Shift+P")); + }); + + it("reads a lone key", () => { + expect(parseWebuiShortcut("F2")).toEqual({ ctrl: false, meta: false, alt: false, shift: false, key: "F2" }); + }); + + /* A modifier with no key is not a shortcut. Accepting it would store a + * binding that fires on Ctrl+anywhere. */ + it("refuses a modifier with no key", () => { + expect(parseWebuiShortcut("Ctrl")).toBeUndefined(); + expect(parseWebuiShortcut("Ctrl+Shift")).toBeUndefined(); + }); + + it("refuses an empty or unknown token", () => { + expect(parseWebuiShortcut("")).toBeUndefined(); + expect(parseWebuiShortcut("Ctrl+Hyper+K")).toBeUndefined(); + }); +}); + +describe("formatting a binding for display", () => { + it("round-trips through parse", () => { + for (const command of WEBUI_SHORTCUT_COMMANDS) { + expect(parseWebuiShortcut(formatWebuiShortcut(command.defaultBinding))).toEqual( + parseWebuiShortcut(command.defaultBinding), + ); + } + }); + + /* macOS users read ⌘, and every other platform reads Ctrl. Showing Ctrl+, + * on a Mac describes a shortcut that does not exist there. */ + it("shows the primary modifier the platform actually uses", () => { + const mac = formatWebuiShortcut("Ctrl+,", "darwin"); + expect(mac).toContain("⌘"); + expect(mac).not.toContain("Ctrl"); + + const other = formatWebuiShortcut("Ctrl+,", "win32"); + expect(other).toContain("Ctrl"); + expect(other).not.toContain("⌘"); + }); + + it("keeps a non-primary modifier visible on both platforms", () => { + expect(formatWebuiShortcut("Ctrl+Shift+P", "darwin")).toContain("⇧"); + }); +}); + +describe("matching a keyboard event", () => { + const press = (binding: string, patch: Partial = {}) => + matchesWebuiShortcut( + { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, key: "?", ...patch }, + binding, + ); + + it("matches the combination it was bound to", () => { + expect(press("Ctrl+,", { ctrlKey: true, key: "," })).toBe(true); + }); + + it("does not match the same key without its modifier", () => { + expect(press("Ctrl+,", { key: "," })).toBe(false); + }); + + /* Holding an extra modifier makes it a different shortcut. Without this the + * user could not press Ctrl+Shift+P for something else while Ctrl+, is + * taken, and the dispatcher would fire on the longer press too. */ + it("does not match when an extra modifier is held", () => { + expect(press("Ctrl+P", { ctrlKey: true, key: "p" })).toBe(true); + expect(press("Ctrl+P", { ctrlKey: true, shiftKey: true, key: "p" })).toBe(false); + }); + + it("does not match a different key with the same modifier", () => { + expect(press("Ctrl+,", { ctrlKey: true, key: "m" })).toBe(false); + }); + + /* Keyboard events report the physical key, so Shift+, arrives as "<". The + * registry stores "Ctrl+Shift+," and it still has to fire. */ + it("matches a shifted punctuation key by its unshifted name", () => { + expect(press("Ctrl+Shift+,", { ctrlKey: true, shiftKey: true, key: "<" })).toBe(true); + }); + + it("matches a letter regardless of case", () => { + expect(press("Ctrl+Shift+P", { ctrlKey: true, shiftKey: true, key: "P" })).toBe(true); + expect(press("Ctrl+Shift+P", { ctrlKey: true, shiftKey: true, key: "p" })).toBe(true); + }); + + it("never matches an unparseable binding", () => { + expect(press("Ctrl+", { ctrlKey: true, key: "," })).toBe(false); + }); +}); + +describe("resolving the effective bindings", () => { + it("falls back to every default when nothing is overridden", () => { + const resolved = resolveWebuiShortcutBindings([]); + expect(resolved.size).toBe(WEBUI_SHORTCUT_COMMANDS.length); + for (const command of WEBUI_SHORTCUT_COMMANDS) { + expect(resolved.get(command.id)).toBe(command.defaultBinding); + } + }); + + it("lets an override win for its own command only", () => { + const overrides: readonly WebuiShortcutOverride[] = [{ id: "openSettings", binding: "Ctrl+Shift+," }]; + const resolved = resolveWebuiShortcutBindings(overrides); + expect(resolved.get("openSettings")).toBe("Ctrl+Shift+,"); + expect(resolved.get("toggleSidebar")).toBe(WEBUI_SHORTCUT_COMMANDS.find((c) => c.id === "toggleSidebar")?.defaultBinding); + }); + + /* A hand-edited localStorage entry, or one written by a build that knew + * about a command this one does not. It must not take the registry down. */ + it("ignores an override that names no known command", () => { + const resolved = resolveWebuiShortcutBindings([{ id: "doesNotExist", binding: "Ctrl+Alt+Q" }]); + expect(resolved.size).toBe(WEBUI_SHORTCUT_COMMANDS.length); + }); + + it("ignores an override whose binding cannot be parsed", () => { + const resolved = resolveWebuiShortcutBindings([{ id: "openSettings", binding: "Ctrl+" }]); + expect(resolved.get("openSettings")).toBe( + WEBUI_SHORTCUT_COMMANDS.find((c) => c.id === "openSettings")?.defaultBinding, + ); + }); +}); + +describe("refusing a binding that is already taken", () => { + const overrides: readonly WebuiShortcutOverride[] = [{ id: "openSettings", binding: "Ctrl+Shift+P" }]; + + it("names the command that already holds it", () => { + const clash = findWebuiShortcutConflict("Ctrl+Shift+P", "toggleSidebar", resolveWebuiShortcutBindings(overrides)); + expect(clash).toBe("openSettings"); + }); + + it("does not report a command as clashing with itself", () => { + const bindings = resolveWebuiShortcutBindings(overrides); + expect(findWebuiShortcutConflict("Ctrl+Shift+P", "openSettings", bindings)).toBeUndefined(); + }); + + it("allows a free binding", () => { + const bindings = resolveWebuiShortcutBindings(overrides); + expect(findWebuiShortcutConflict("Ctrl+Alt+9", "toggleSidebar", bindings)).toBeUndefined(); + }); +}); + +describe("resetting", () => { + it("drops the override for one command and keeps the others", () => { + const overrides: readonly WebuiShortcutOverride[] = [ + { id: "openSettings", binding: "Ctrl+Shift+P" }, + { id: "toggleSidebar", binding: "Ctrl+Alt+B" }, + ]; + const after = resetWebuiShortcutOverrides(overrides, "openSettings"); + expect(after.some((entry) => entry.id === "openSettings")).toBe(false); + expect(after.some((entry) => entry.id === "toggleSidebar")).toBe(true); + }); +}); + +describe("the registry itself", () => { + it("gives every command a label, a group and a parseable default", () => { + for (const command of WEBUI_SHORTCUT_COMMANDS) { + expect(command.label.trim().length).toBeGreaterThan(0); + expect(command.group.trim().length).toBeGreaterThan(0); + expect(parseWebuiShortcut(command.defaultBinding)).toBeDefined(); + } + }); + + /* Two defaults on the same combination means the dispatcher can only ever + * run one of them, and the settings page would show the same key twice. */ + it("never ships two commands on one combination", () => { + const seen = new Map(); + for (const command of WEBUI_SHORTCUT_COMMANDS) { + const canonical = formatWebuiShortcut(command.defaultBinding); + const owner = seen.get(canonical); + expect(owner, `${command.id} duplicates ${owner} on ${canonical}`).toBeUndefined(); + seen.set(canonical, command.id); + } + }); + + it("uses distinct ids", () => { + const ids = WEBUI_SHORTCUT_COMMANDS.map((command) => command.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + /* Escape and the arrows are how menus close and lists navigate. Letting a + * user rebind them would break every popover in the app, so no command may + * default to one. */ + it("never defaults to Escape or a bare arrow key", () => { + for (const command of WEBUI_SHORTCUT_COMMANDS) { + const binding = parseWebuiShortcut(command.defaultBinding); + expect(["escape", "arrowup", "arrowdown", "arrowleft", "arrowright", "enter", " "]).not.toContain(binding?.key); + } + }); +}); + +describe("capturing a key the user just pressed", () => { + const press = (patch: Partial & { key: string }) => + webuiShortcutFromEvent({ + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + ...patch, + }); + + it("records the combination literally", () => { + expect(press({ ctrlKey: true, key: "K" })).toBe("Ctrl+k"); + expect(press({ ctrlKey: true, shiftKey: true, key: "P" })).toBe("Ctrl+Shift+p"); + expect(press({ metaKey: true, key: "k" })).toBe("Meta+k"); + }); + + /* A rebind the user cannot back out of is worse than no rebind at all. */ + it("refuses a lone modifier, which is a press in progress", () => { + expect(press({ ctrlKey: true, key: "Control" })).toBeUndefined(); + expect(press({ ctrlKey: true, key: "Shift" })).toBeUndefined(); + }); + + it("refuses Escape, which is the cancel gesture", () => { + expect(press({ ctrlKey: true, key: "Escape" })).toBeUndefined(); + }); + + it("refuses a bare letter, which is just typing", () => { + expect(press({ key: "k" })).toBeUndefined(); + }); + + it("accepts a function key without a modifier", () => { + expect(press({ key: "F2" })).toBe("F2"); + }); + + /* Round-tripping is what stops a captured binding from being dropped on the + * next read and the user's choice silently reverting. */ + it("always produces something the registry can read back", () => { + for (const patch of [ + { ctrlKey: true, key: "," }, + { ctrlKey: true, shiftKey: true, key: "," }, + { metaKey: true, altKey: true, key: "k" }, + { key: "F12" }, + ] as const) { + const binding = press({ ...patch }); + expect(parseWebuiShortcut(binding ?? "")).toBeDefined(); + } + }); +}); + +/* The registry is pure and testable; the page that renders it is not reachable + * from `renderToStaticMarkup` in any useful way, so it is asserted directly as + * an exported component and the click handlers that matter are covered by the + * wiring assertions below. */ +describe("the shortcut settings page", () => { + const render = (props: Record = {}): string => + renderToStaticMarkup( + createElement(WebuiShortcutSettings, { overrides: [], platform: "win32", ...props } as never), + ); + + it("renders a row for every registered command, with its default binding", () => { + const markup = render(); + expect(markup).toContain('data-testid="settings-shortcut-page"'); + for (const command of WEBUI_SHORTCUT_COMMANDS) { + expect(markup).toContain(`data-webui-shortcut-id="${command.id}"`); + expect(markup).toContain(command.label); + } + expect(markup).toContain("Ctrl+,"); + }); + + it("shows the effective binding, not the default, once one is overridden", () => { + expect(render({ overrides: [{ id: "openSettings", binding: "Ctrl+Shift+," }] })).toContain("Ctrl+Shift+,"); + }); + + /* Without this the user cannot tell a rebound key from the shipped one, and + * "reset" would appear to do nothing. */ + it("marks a row that no longer uses its default", () => { + expect(render({ overrides: [{ id: "openSettings", binding: "Ctrl+Shift+," }] })).toContain( + 'data-webui-shortcut-overridden="true"', + ); + }); + + it("offers a reset only for a row that was overridden", () => { + const overridden = render({ overrides: [{ id: "openSettings", binding: "Ctrl+Shift+," }] }); + expect(overridden.match(/data-testid="shortcut-reset"/gu)?.length ?? 0).toBe(1); + expect(render().match(/data-testid="shortcut-reset"/gu)?.length ?? 0).toBe(0); + }); + + it("puts the commands into their groups", () => { + const markup = render(); + for (const group of new Set(WEBUI_SHORTCUT_COMMANDS.map((command) => command.group))) { + expect(markup).toContain(`>${group} { + const owner = WEBUI_SHORTCUT_COMMANDS.find((command) => command.id === "toggleSidebar"); + const markup = render({ + editing: "focusComposer", + conflict: { commandId: "toggleSidebar", label: owner?.label ?? "" }, + }); + expect(markup).toContain('data-testid="shortcut-conflict"'); + expect(markup).toContain('role="alert"'); + expect(markup).toContain(owner?.label ?? ""); + }); + + it("shows the capture prompt only for the row being edited", () => { + expect(render({ editing: "focusComposer" })).toContain('data-testid="shortcut-capture"'); + expect(render()).not.toContain('data-testid="shortcut-capture"'); + }); +}); + +describe("parsing stored overrides", () => { + it("reads a well-formed list", () => { + expect(parseWebuiShortcutOverrides('[{"id":"openSettings","binding":"Ctrl+Shift+,"}]')).toEqual([ + { id: "openSettings", binding: "Ctrl+Shift+," }, + ]); + }); + + /* This comes out of localStorage, which survives a downgrade of this build. + * A malformed entry must not stop the settings page from opening. */ + it("survives a hand-edited or stale value", () => { + expect(parseWebuiShortcutOverrides("{not json")).toEqual([]); + expect(parseWebuiShortcutOverrides('{"id":"x"}')).toEqual([]); + expect(parseWebuiShortcutOverrides('[{"id":"openSettings"}]')).toEqual([]); + expect(parseWebuiShortcutOverrides(null)).toEqual([]); + }); +}); + +/* Rows render above. These close the two loops a render cannot reach: that the + * page is reachable from the tab at all, and that a global handler exists — + * without which every binding in the registry is decoration, which is the + * defect this change exists to remove. */ +describe("wiring", () => { + const source = readFileSync( + fileURLToPath(new URL("../../src/client/components/SettingsModal.tsx", import.meta.url)), + "utf8", + ); + const app = readFileSync( + fileURLToPath(new URL("../../src/client/components/WebuiClientFoundationApp.tsx", import.meta.url)), + "utf8", + ); + + it("routes the shortcuts tab to its own page", () => { + expect(source).toContain('{active === "shortcuts" ? { + const guard = source.split("webui-settings-empty-panel")[0]?.split('{active === "worktree"')?.at(-1) ?? ""; + expect(guard).toContain('active !== "shortcuts"'); + }); + + it("stops excluding the shortcuts tab from the settings list", () => { + expect(source).not.toContain('key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true'); + }); + + it("installs one document-level key handler in the shell", () => { + expect(app).toContain('document.addEventListener("keydown"'); + expect(app).toContain("resolveWebuiShortcutBindings"); + }); + + /* The registry resolving is worth nothing if the handler feeds it an empty + * list: the page would show saved keys and the shell would keep firing the + * defaults. This is the seam between the two halves of the feature. */ + it("feeds the handler the user's saved overrides", () => { + expect(app).toContain("WEBUI_SHORTCUT_OVERRIDES_KEY"); + expect(app).toContain("parseWebuiShortcutOverrides(localStorage.getItem(WEBUI_SHORTCUT_OVERRIDES_KEY))"); + }); + + /* Each registered id must have a branch behind it. A command in the registry + * with no handler is exactly the "a row that says something and does + * nothing" bug. */ + it("gives every registered command an action in the shell", () => { + for (const command of WEBUI_SHORTCUT_COMMANDS) { + expect(app, `no handler for ${command.id}`).toContain(`"${command.id}"`); + } + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 7d13fcd2..cf1362d7 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3626,6 +3626,7 @@ "packages/webui/src/client/projection/questionnaire-state.ts", "packages/webui/src/client/projection/review-state.ts", "packages/webui/src/client/projection/shell-surface.ts", + "packages/webui/src/client/projection/shortcut-state.ts", "packages/webui/src/client/projection/thinking-control.ts", "packages/webui/src/client/projection/token-plan-model.ts", "packages/webui/src/client/projection/tool-projection.ts", @@ -3759,6 +3760,7 @@ "packages/webui/test/unit/settings-modal.test.tsx", "packages/webui/test/unit/settings-search-highlight.test.ts", "packages/webui/test/unit/shell-surface.test.ts", + "packages/webui/test/unit/shortcut-state.test.ts", "packages/webui/test/unit/slash-classification.test.ts", "packages/webui/test/unit/slash-palette.test.ts", "packages/webui/test/unit/team-mode.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index e19d9583..5114f0b5 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -280,6 +280,7 @@ "packages/webui/test/unit/worktree-panel.test.tsx", "packages/webui/test/unit/worktree-state.test.ts", "packages/webui/test/unit/review-panel.test.tsx", + "packages/webui/test/unit/shortcut-state.test.ts", "packages/webui/test/unit/review-state.test.ts", "packages/webui/test/unit/webui-round3-acceptance.test.tsx", "packages/webui/test/unit/webui-w0-store-sequence.test.ts", diff --git a/test/webui-browser/settings-account-tab.spec.mjs b/test/webui-browser/settings-account-tab.spec.mjs index e7916d1f..65d5ce10 100644 --- a/test/webui-browser/settings-account-tab.spec.mjs +++ b/test/webui-browser/settings-account-tab.spec.mjs @@ -107,13 +107,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 + // 代码审查, 工作树 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", "shortcuts", "connection"]) { + 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` From ee0d5dd1d93ac54b956a34fde8b925e7f59be1ca Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:31:49 +0800 Subject: [PATCH 2/4] feat(webui): voice input, and a mic that only appears where it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap P 区「语音输入」. The ledger records this row as "the tab is disabled", which is accurate and also understates it: there is nothing behind the tab at all. The client has no getUserMedia, no SpeechRecognition, and no voice state of any kind. The whole feature turns on one honest question — what does this browser do when nobody is listening? The Web Speech API is Chromium and partly Safari; Firefox has never shipped it. A mic button that appears and then fails costs the user a click and tells them nothing, so "can this browser listen" is a first-class state in the reducer rather than a boolean folded away at the call site. When the answer is no, the composer renders no button at all and the settings page says which browsers would work. The other decision worth freezing is what a recognition result does to text the user has already typed. The recogniser revises its interim text continuously while the user keeps typing, so the state records where this utterance began and every interim update replaces exactly the segment this utterance contributed — everything before and after it survives. Cancelling throws the segment away entirely: the user pressed stop, they did not ask for half a word. A failure keeps whatever was already recognised, because those words were on screen. `voice-state.ts` is pure and DOM-free, which is what makes those decisions testable at all — the webui suite has no jsdom and the recogniser itself needs a microphone. `use-webui-voice.ts` holds the lifecycle glue and declares the non-standard global narrowly rather than pulling in a DOM lib that has it. The one rule that file exists to enforce: no recogniser is constructed until the user asks for one. Validation - pnpm check:source exit 0 (4758 files) - pnpm typecheck:webui-full exit 0 - pnpm build:webui exit 0 - run-vitest-suite.mjs webui 10 failed | 1917 passed | 4 skipped The 10 failures are the unchanged pre-existing Windows baseline: 7 webui-boundary-check path assertions, 2 webui-design-tokens path assertions, 1 webui-service shutdown timeout. Confirmed on a clean tree by stashing. Browser suite: NOT clean, and not attributable to this change. On this branch 19 of 96 fail; on a clean tree of the same base with this work stashed, 20 of 96 fail — the clean tree is one worse. The run also went from 56s earlier today to ~9.3 minutes. Ruled out so far: `reuseExistingServer` is false by default in playwright.config.mjs, so no stale server was adopted; killing 25 orphaned chrome and 17 orphaned node processes left both numbers unchanged; and the config sets no test timeout, so the `--timeout=30000` used in these runs was a no-op. What remains is not something I can attribute, and it is not something I can call a passing gate. Someone should look at the machine. Test evidence 27 tests in voice-state.test.ts, registered in test/vitest-suites.json. Red before the implementation: the module did not exist, the component was not exported, the tab was not routed, the tab was still disabled, and the composer had no mic. Negative injection: 21 mutations across the reducer, the page, the composer and the hook, 0 survivors, implementation restored byte-for-byte. Two real holes, both from this change's own wiring: - The composer assertion only checked that `data-testid="composer-voice"` appears in the file. Replacing `{voice.available ? (` with `{false ? (` left every assertion green while removing the button from the composer entirely. It now asserts the guard and the control as one piece of source. - A guard assertion for "the mic must not appear before the feature is on" passed against the wrong file: the guard lives in `use-webui-voice.ts`, and the mutation was filed against the composer, where it matched nothing and was correctly reported STALE rather than counted as killed. A third hole was in my own code and is worth recording. The hook mirrors the reducer's draft back into the composer, and unguarded it compares a fresh state's `""` against whatever the user has typed — so it clears the composer on the first keystroke. It is fixed by a touched-flag, and negative injection now covers that guard. The fix itself is a source assertion, not a behavioural test: the browser fixture hand-rolls its own textarea, so no browser test here reaches this hook. I am not claiming a green suite verified it. Existing tests moved their tab lists rather than losing their teeth: settings-modal.test.tsx and settings-account-tab.spec.mjs now place `voice` in the implemented group and still assert `connection` is disabled, which is what keeps either check from being vacuous. Scope: second of the three rows in P 区. 多语言 is a full extraction — 8604 CJK characters across 77 of 109 client files, with `mavis-locale` currently read by a `useState` that has no setter — and is deliberately not in this change. --- .../src/client/components/SessionComposer.tsx | 31 +++ .../src/client/components/SettingsModal.tsx | 74 ++++- .../src/client/projection/voice-state.ts | 156 +++++++++++ packages/webui/src/client/use-webui-voice.ts | 164 +++++++++++ .../webui/test/unit/settings-modal.test.tsx | 11 +- packages/webui/test/unit/voice-state.test.ts | 262 ++++++++++++++++++ release/public-source.json | 3 + test/vitest-suites.json | 1 + .../settings-account-tab.spec.mjs | 12 +- 9 files changed, 700 insertions(+), 14 deletions(-) create mode 100644 packages/webui/src/client/projection/voice-state.ts create mode 100644 packages/webui/src/client/use-webui-voice.ts create mode 100644 packages/webui/test/unit/voice-state.test.ts diff --git a/packages/webui/src/client/components/SessionComposer.tsx b/packages/webui/src/client/components/SessionComposer.tsx index 0330900c..d2eb6c5a 100644 --- a/packages/webui/src/client/components/SessionComposer.tsx +++ b/packages/webui/src/client/components/SessionComposer.tsx @@ -124,6 +124,7 @@ import { WebuiIconSkillGeneric, type WebuiIconProps, } from "../icons.js"; +import { useWebuiVoice } from "../use-webui-voice.js"; import { OutputError } from "./OutputError.js"; import { createWebuiWatchEventCallback, @@ -1675,6 +1676,19 @@ export function WebuiComposer({ } onDraftChange(next); }; + + /* Roadmap P 区「语音输入」. + * + * The button only exists when the feature is switched on AND this browser + * can actually listen. Firefox has never shipped `SpeechRecognition`, and a + * control that appears and then never reacts costs the user a click and + * explains nothing — so the unsupported case renders no button at all, and + * the settings page says why. + * + * Recognised text goes through `handleDraftChange` rather than straight to + * the setter so it lands in the draft the same way typing does, including + * the `/goal` command interception. */ + const voice = useWebuiVoice({ draft, setDraft: handleDraftChange }); const chooseCommand = (command: string) => { if (command === "goal") { activateGoalMode(); @@ -2434,6 +2448,23 @@ export function WebuiComposer({ ) : null} + {voice.available ? ( + + ) : null}
setQuery(event.target.value)} placeholder="搜索设置..." aria-label="搜索设置" />{query ? : null}

{label}

{active === "archived" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active === "shortcuts" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "shortcuts" && active !== "custom-instructions" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

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

{label}

{active === "archived" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active === "voice" ? : null}{active === "shortcuts" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "voice" && active !== "shortcuts" && active !== "custom-instructions" ?
: 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 { @@ -451,7 +459,67 @@ export function WebuiReviewWorkspacePicker({ workspaces, selected, loading, erro ; } -/* Roadmap P 区「快捷键管理」. +/* Roadmap P 区「语音输入」. + * + * The page says the one thing a user cannot work out on their own: whether + * this browser can listen at all. Firefox has never shipped the Web Speech + * API, and on it a mic button is a control that costs a click and explains + * nothing. Everything else on this page is the plain setting behind it. */ +export function WebuiVoiceSettings({ settings, support, onToggle, onLanguageChange }: { + readonly settings: WebuiVoiceSettingsValue; + readonly support: WebuiVoiceSupport; + readonly onToggle?: (enabled: boolean) => void; + readonly onLanguageChange?: (language: string) => void; +}): ReactElement { + return
+ {support === "unsupported" ? ( +

+ 当前浏览器不支持语音识别,输入框里的麦克风按钮不会出现。换 Chrome 或 Edge 就能用。 +

+ ) : null} + + +

识别到的文字会插入光标位置,中途结果会实时更新。

+
; +} + +function SettingsVoicePage(): ReactElement { + const [settings, setSettings] = useState(() => parseWebuiVoiceSettings(null)); + const [support, setSupport] = useState("unknown"); + + useEffect(() => { + setSettings(parseWebuiVoiceSettings(localStorage.getItem(WEBUI_VOICE_SETTINGS_KEY))); + setSupport(typeof window === "undefined" ? "unknown" : resolveWebuiVoiceSupport(window)); + }, []); + + const write = useCallback((next: WebuiVoiceSettingsValue) => { + setSettings(next); + localStorage.setItem(WEBUI_VOICE_SETTINGS_KEY, JSON.stringify(next)); + }, []); + + return write({ ...settings, enabled })} + onLanguageChange={(language) => write({ ...settings, language })} + />; +} + +/* Rebindable command list (roadmap P 区「快捷键管理」). * * Presentational like the review and worktree panels, so the rows it renders * are assertable without a DOM. Which commands exist and how a binding is diff --git a/packages/webui/src/client/projection/voice-state.ts b/packages/webui/src/client/projection/voice-state.ts new file mode 100644 index 00000000..0760ee9b --- /dev/null +++ b/packages/webui/src/client/projection/voice-state.ts @@ -0,0 +1,156 @@ +/* Roadmap P 区「语音输入」. + * + * The Web Speech API is Chromium and partly Safari. Firefox has never shipped + * it. A mic button that appears and then fails costs the user a click and tells + * them nothing, so "can this browser listen" is a first-class state here + * rather than a boolean folded away at the call site. + * + * The recogniser revises its own interim text continuously, and the user keeps + * typing while it does. So the state tracks where this utterance began, and + * every interim update replaces exactly the segment this utterance contributed + * — everything before and after it survives untouched. Getting that wrong + * silently eats whatever the user was in the middle of writing. + */ + +export type WebuiVoiceSupport = "unknown" | "supported" | "unsupported"; +export type WebuiVoicePhase = "idle" | "listening"; + +export interface WebuiVoiceState { + readonly phase: WebuiVoicePhase; + readonly support: WebuiVoiceSupport; + /** The composer text, mirrored so the reducer can place the segment. */ + readonly draft: string; + /** Index in `draft` where this utterance's text begins. */ + readonly segmentStart: number; + /** What the recogniser has produced so far for this utterance. */ + readonly interim: string; + readonly error?: string; +} + +export type WebuiVoiceAction = + | { readonly type: "support-resolved"; readonly support: WebuiVoiceSupport } + | { readonly type: "start"; readonly draft: string; readonly at: number } + | { readonly type: "interim"; readonly text: string } + | { readonly type: "final"; readonly text: string } + | { readonly type: "failed"; readonly reason: string } + | { readonly type: "cancelled" }; + +export interface WebuiVoiceSettings { + readonly enabled: boolean; + readonly language: string; +} + +export interface WebuiVoiceLanguage { + readonly tag: string; + readonly label: string; +} + +/** + * A recogniser language the page offers. Kept small and explicit: a free-text + * tag the browser silently rejects is worse than a list the user can pick from. + */ +export const WEBUI_VOICE_LANGUAGES: readonly WebuiVoiceLanguage[] = [ + { tag: "zh-CN", label: "中文(普通话)" }, + { tag: "zh-TW", label: "中文(台湾)" }, + { tag: "en-US", label: "English (US)" }, + { tag: "en-GB", label: "English (UK)" }, + { tag: "ja-JP", label: "日本語" }, +]; + +export const WEBUI_VOICE_SETTINGS_KEY = "webui-voice-settings"; + +const DEFAULT_VOICE_SETTINGS: WebuiVoiceSettings = { + enabled: false, + language: WEBUI_VOICE_LANGUAGES[0]?.tag ?? "zh-CN", +}; + +export function initialWebuiVoiceState(): WebuiVoiceState { + return { phase: "idle", support: "unknown", draft: "", segmentStart: 0, interim: "" }; +} + +export function resolveWebuiVoiceSupport(scope: unknown): WebuiVoiceSupport { + const globals = (scope ?? {}) as { SpeechRecognition?: unknown; webkitSpeechRecognition?: unknown }; + return globals.SpeechRecognition ?? globals.webkitSpeechRecognition ? "supported" : "unsupported"; +} + +/** Replaces the segment this utterance owns, leaving the rest of the draft. */ +function spliceSegment(state: WebuiVoiceState, replacement: string): string { + const head = state.draft.slice(0, state.segmentStart); + const tail = state.draft.slice(state.segmentStart + state.interim.length); + return `${head}${replacement}${tail}`; +} + +export function reduceWebuiVoiceState( + state: WebuiVoiceState, + action: WebuiVoiceAction, +): WebuiVoiceState { + switch (action.type) { + case "support-resolved": + return { ...state, support: action.support }; + + case "start": { + // Starting on a browser that cannot listen would leave the user watching + // a button that never reacts, so the session never begins. + if (state.support !== "supported") return state; + const at = Math.max(0, Math.min(action.at, action.draft.length)); + return { + ...state, + phase: "listening", + draft: action.draft, + segmentStart: at, + interim: "", + error: undefined, + }; + } + + case "interim": { + // Results arriving with no session running are a recogniser bug or a + // late callback after cancel; either way there is no segment to replace. + if (state.phase !== "listening") return state; + return { ...state, interim: action.text, draft: spliceSegment(state, action.text) }; + } + + case "final": { + if (state.phase !== "listening") return state; + const draft = spliceSegment(state, action.text); + return { ...state, phase: "idle", draft, interim: "", error: undefined }; + } + + case "failed": { + if (state.phase !== "listening") return state; + // Whatever was recognised before the failure stays in the draft. Throwing + // it away would lose words the user already saw. + return { ...state, phase: "idle", interim: "", error: action.reason }; + } + + case "cancelled": { + if (state.phase !== "listening") return state; + // The user pressed stop. They did not ask for half a word. + return { ...state, phase: "idle", draft: spliceSegment(state, ""), interim: "", error: undefined }; + } + + default: + return state; + } +} + +export function parseWebuiVoiceSettings(raw: string | null | undefined): WebuiVoiceSettings { + if (!raw) return DEFAULT_VOICE_SETTINGS; + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null) return DEFAULT_VOICE_SETTINGS; + const candidate = value as Partial; + // A partial object is treated as absent rather than merged. Half a setting + // would otherwise leave the mic permanently on with no language to use. + if (typeof candidate.enabled !== "boolean" || typeof candidate.language !== "string") { + return DEFAULT_VOICE_SETTINGS; + } + const known = WEBUI_VOICE_LANGUAGES.some((entry) => entry.tag === candidate.language); + return { + enabled: candidate.enabled, + language: known ? candidate.language : DEFAULT_VOICE_SETTINGS.language, + }; + } catch { + return DEFAULT_VOICE_SETTINGS; + } +} diff --git a/packages/webui/src/client/use-webui-voice.ts b/packages/webui/src/client/use-webui-voice.ts new file mode 100644 index 00000000..45ae7760 --- /dev/null +++ b/packages/webui/src/client/use-webui-voice.ts @@ -0,0 +1,164 @@ +/* Roadmap P 区「语音输入」. + * + * Everything here is lifecycle glue around the Web Speech API and the pure + * reducer in `voice-state.ts`. The recogniser is a non-standard global, so its + * shape is declared narrowly rather than pulling in a DOM lib that has it. + * + * The rule the whole file exists to enforce: **no recogniser is constructed + * until the user asks for one.** Chrome's implementation opens a microphone + * stream as soon as `start()` is called, and constructing the object does not + * — but constructing it eagerly on mount would still leave a live object + * around on every page load for a feature most users never turn on. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + WEBUI_VOICE_SETTINGS_KEY, + initialWebuiVoiceState, + parseWebuiVoiceSettings, + reduceWebuiVoiceState, + resolveWebuiVoiceSupport, + type WebuiVoiceSettings, + type WebuiVoiceState, +} from "./projection/voice-state.js"; + +interface WebuiSpeechRecognitionAlternativeLike { + readonly transcript: string; +} +interface WebuiSpeechRecognitionResultLike { + readonly isFinal: boolean; + readonly length: number; + [index: number]: WebuiSpeechRecognitionAlternativeLike | undefined; +} +interface WebuiSpeechRecognitionEventLike { + readonly resultIndex: number; + readonly results: { + readonly length: number; + [index: number]: WebuiSpeechRecognitionResultLike | undefined; + }; +} +interface WebuiSpeechRecognitionLike { + lang: string; + continuous: boolean; + interimResults: boolean; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: WebuiSpeechRecognitionEventLike) => void) | null; + onerror: ((event: { readonly error?: string }) => void) | null; + onend: (() => void) | null; +} +type WebuiSpeechRecognitionCtor = new () => WebuiSpeechRecognitionLike; + +function speechRecognitionCtor(): WebuiSpeechRecognitionCtor | undefined { + if (typeof window === "undefined") return undefined; + const scope = window as unknown as { SpeechRecognition?: WebuiSpeechRecognitionCtor; webkitSpeechRecognition?: WebuiSpeechRecognitionCtor }; + return scope.SpeechRecognition ?? scope.webkitSpeechRecognition; +} + +export interface WebuiVoiceControls { + readonly state: WebuiVoiceState; + readonly settings: WebuiVoiceSettings; + /** True only when the feature is on and this browser can actually listen. */ + readonly available: boolean; + readonly toggle: () => void; + readonly cancel: () => void; +} + +export function useWebuiVoice(options: { + readonly draft: string; + readonly setDraft: (next: string) => void; +}): WebuiVoiceControls { + const { draft, setDraft } = options; + const [state, setState] = useState(initialWebuiVoiceState); + const [settings, setSettings] = useState(() => parseWebuiVoiceSettings(null)); + const recognitionRef = useRef(undefined); + // Set when a session starts, cleared once the reducer's draft has been + // pushed out. Without it the mirror below compares an untouched + // `state.draft` of "" against whatever the user has typed and clears the + // composer on the first keystroke. + const voiceTouchedRef = useRef(false); + + useEffect(() => { + setSettings(parseWebuiVoiceSettings(localStorage.getItem(WEBUI_VOICE_SETTINGS_KEY))); + setState((current) => reduceWebuiVoiceState(current, { + type: "support-resolved", + support: speechRecognitionCtor() ? "supported" : "unsupported", + })); + }, []); + + // The reducer decides what the draft becomes; this only pushes that out, and + // only while a voice session is responsible for the text in it. + useEffect(() => { + if (!voiceTouchedRef.current || state.draft === draft) return; + voiceTouchedRef.current = false; + setDraft(state.draft); + }, [state.draft, draft, setDraft]); + + const stop = useCallback((action: "final" | "cancelled" | "failed", payload?: string) => { + recognitionRef.current?.abort(); + recognitionRef.current = undefined; + setState((current) => reduceWebuiVoiceState( + current, + action === "final" + ? { type: "final", text: payload ?? current.interim } + : action === "failed" + ? { type: "failed", reason: payload ?? "unknown" } + : { type: "cancelled" }, + )); + }, []); + + const toggle = useCallback(() => { + if (state.phase === "listening") { + stop("final"); + return; + } + const Ctor = speechRecognitionCtor(); + if (!Ctor) return; + const textarea = typeof document === "undefined" ? undefined : document.querySelector('textarea[name="content"]'); + voiceTouchedRef.current = true; + setState((current) => reduceWebuiVoiceState(current, { + type: "start", + draft, + at: textarea?.selectionStart ?? draft.length, + })); + + const recognition = new Ctor(); + recognition.lang = settings.language; + // Interim results are the point: the user watches the sentence assemble + // instead of waiting for the recogniser to declare it finished. + recognition.continuous = false; + recognition.interimResults = true; + recognition.onresult = (event) => { + let interim = ""; + for (let index = event.resultIndex; index < event.results.length; index += 1) { + const result = event.results[index]; + const text = result?.[0]?.transcript ?? ""; + if (result?.isFinal) { + stop("final", text); + return; + } + interim += text; + } + setState((current) => reduceWebuiVoiceState(current, { type: "interim", text: interim })); + }; + recognition.onerror = (event) => stop("failed", event.error ?? "unknown"); + recognition.onend = () => { + if (recognitionRef.current) stop("final"); + }; + recognitionRef.current = recognition; + recognition.start(); + }, [draft, settings.language, state.phase, stop]); + + const cancel = useCallback(() => stop("cancelled"), [stop]); + + useEffect(() => () => recognitionRef.current?.abort(), []); + + return { + state, + settings, + available: settings.enabled && state.support === "supported", + toggle, + cancel, + }; +} diff --git a/packages/webui/test/unit/settings-modal.test.tsx b/packages/webui/test/unit/settings-modal.test.tsx index 2235f222..5d5fc2e0 100644 --- a/packages/webui/test/unit/settings-modal.test.tsx +++ b/packages/webui/test/unit/settings-modal.test.tsx @@ -95,13 +95,14 @@ describe("account tab gating", () => { // not-yet-built panels still need. `account` (email row, sign-out button, // sign-out error region), `coding` (workspace review), `worktree` (parallel // experiment branches), `custom-instructions` (profile-wide AGENTS.md - // editor) and `shortcuts` (rebindable command registry) 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"]) { + // editor), `shortcuts` (rebindable command registry) and `voice` (browser + // speech support and language) are all built, so all six 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", "voice"]) { expect(DESKTOP_SETTINGS_TABS.find((tab) => tab.key === key)?.disabled).toBeUndefined(); } - for (const key of ["voice", "connection"]) { + for (const key of ["connection"]) { expect(DESKTOP_SETTINGS_TABS.find((tab) => tab.key === key)?.disabled).toBe(true); } }); diff --git a/packages/webui/test/unit/voice-state.test.ts b/packages/webui/test/unit/voice-state.test.ts new file mode 100644 index 00000000..a0f598a0 --- /dev/null +++ b/packages/webui/test/unit/voice-state.test.ts @@ -0,0 +1,262 @@ +/* Roadmap P 区「语音输入」. + * + * There is nothing to fix here and nothing to extend: the client has no + * `getUserMedia`, no `SpeechRecognition`, and no voice state of any kind. The + * tab is disabled because there is nothing behind it. + * + * So the whole feature rests on one honest question — what does this browser + * do when nobody is listening? The Web Speech API is Chromium and partly + * Safari; Firefox has never shipped it. A mic button that appears and then + * fails is worse than no mic button, because it costs the user a click and + * tells them nothing. So support detection is a first-class state, not a + * boolean folded away at the call site, and it is asserted here rather than + * left to whoever wires the button. + * + * The other decision worth freezing is what a recognition result does to text + * the user has already typed. Interim results arrive continuously and revise + * themselves, so the state's job is to replace exactly the segment this + * utterance contributed and leave everything around it alone. Getting that + * wrong silently eats the user's half-written sentence. + * + * Everything here is pure. The webui suite has no jsdom, so the recogniser + * itself is driven through the state machine and the presentational half lives + * in `SettingsModal.tsx`. + */ + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { WebuiVoiceSettings } from "../../src/client/components/SettingsModal.js"; +import { + WEBUI_VOICE_LANGUAGES, + WEBUI_VOICE_SETTINGS_KEY, + initialWebuiVoiceState, + reduceWebuiVoiceState, + resolveWebuiVoiceSupport, + parseWebuiVoiceSettings, + type WebuiVoiceSettings as WebuiVoiceSettingsValue, +} from "../../src/client/projection/voice-state.js"; + +describe("deciding whether this browser can listen", () => { + const support = (patch: Partial>) => + resolveWebuiVoiceSupport(patch as never); + + it("accepts a browser that exposes the recogniser either way", () => { + expect(support({ SpeechRecognition: class {} })).toBe("supported"); + expect(support({ webkitSpeechRecognition: class {} })).toBe("supported"); + }); + + /* Firefox, and any browser where the feature is off. This is the case the + * mic button has to be built around. */ + it("reports a browser with neither spelling as unsupported", () => { + expect(support({})).toBe("unsupported"); + expect(support({ SpeechRecognition: undefined })).toBe("unsupported"); + }); + + it("does not start a session on a browser that cannot listen", () => { + const state = reduceWebuiVoiceState( + { ...initialWebuiVoiceState(), support: "unsupported" }, + { type: "start", draft: "hello", at: 0 }, + ); + expect(state.phase).toBe("idle"); + }); + + it("starts a session on one that can", () => { + const state = reduceWebuiVoiceState( + { ...initialWebuiVoiceState(), support: "supported" }, + { type: "start", draft: "hello", at: 0 }, + ); + expect(state.phase).toBe("listening"); + }); +}); + +describe("recognised speech into a draft the user has already typed", () => { + const listening = (draft: string, at = 0) => + reduceWebuiVoiceState( + { ...initialWebuiVoiceState(), support: "supported" }, + { type: "start", draft, at }, + ); + + /* The whole point of tracking where the utterance began: the recogniser + * revises its own interim text, and only that segment may be replaced. */ + it("leaves text before the utterance alone", () => { + const state = listening("hello ", 6); + expect(state.segmentStart).toBe(6); + expect(state.draft).toBe("hello "); + }); + + it("shows interim text as it is still being revised", () => { + const state = reduceWebuiVoiceState(listening("hello ", 6), { type: "interim", text: "wor" }); + expect(state.interim).toBe("wor"); + expect(state.draft).toBe("hello wor"); + }); + + it("replaces the interim segment with the final one", () => { + const withInterim = reduceWebuiVoiceState(listening("hello ", 6), { type: "interim", text: "wor" }); + const final = reduceWebuiVoiceState(withInterim, { type: "final", text: "world" }); + expect(final.draft).toBe("hello world"); + expect(final.interim).toBe(""); + expect(final.phase).toBe("idle"); + }); + + it("keeps text typed after the utterance began", () => { + const withInterim = reduceWebuiVoiceState(listening("", 0), { type: "interim", text: "hi" }); + const typed = { ...withInterim, draft: "hi there" }; + expect(reduceWebuiVoiceState(typed, { type: "final", text: "hey" }).draft).toBe("hey there"); + }); + + it("does not duplicate the segment when the final equals the interim", () => { + const withInterim = reduceWebuiVoiceState(listening("", 0), { type: "interim", text: "hi" }); + expect(reduceWebuiVoiceState(withInterim, { type: "final", text: "hi" }).draft).toBe("hi"); + }); + + it("goes back to idle after a failure, keeping what was recognised", () => { + const withInterim = reduceWebuiVoiceState(listening("", 0), { type: "interim", text: "part" }); + const failed = reduceWebuiVoiceState(withInterim, { type: "failed", reason: "no-speech" }); + expect(failed.phase).toBe("idle"); + expect(failed.draft).toBe("part"); + expect(failed.error).toBe("no-speech"); + }); + + it("clears the error when the next session starts", () => { + const failed = reduceWebuiVoiceState( + reduceWebuiVoiceState(listening("", 0), { type: "interim", text: "x" }), + { type: "failed", reason: "no-speech" }, + ); + expect(reduceWebuiVoiceState(failed, { type: "start", draft: "y", at: 0 }).error).toBeUndefined(); + }); + + /* A cancelled session must leave the draft exactly as it was — the user + * pressed stop, they did not ask for half a word. */ + it("throws away the interim segment when the user cancels", () => { + const withInterim = reduceWebuiVoiceState(listening("keep ", 5), { type: "interim", text: "junk" }); + const cancelled = reduceWebuiVoiceState(withInterim, { type: "cancelled" }); + expect(cancelled.draft).toBe("keep "); + expect(cancelled.phase).toBe("idle"); + }); + + it("ignores recognition results that arrive with no session running", () => { + const idle = initialWebuiVoiceState(); + expect(reduceWebuiVoiceState(idle, { type: "interim", text: "ghost" }).interim).toBe(""); + }); +}); + +describe("the recogniser languages the settings page offers", () => { + it("offers only tags it can label", () => { + for (const entry of WEBUI_VOICE_LANGUAGES) { + expect(entry.tag.trim().length).toBeGreaterThan(0); + expect(entry.label.trim().length).toBeGreaterThan(0); + } + }); + + it("does not offer the same tag twice", () => { + expect(new Set(WEBUI_VOICE_LANGUAGES.map((entry) => entry.tag)).size).toBe(WEBUI_VOICE_LANGUAGES.length); + }); +}); + +describe("settings that survive a reload", () => { + const defaults: WebuiVoiceSettingsValue = { enabled: false, language: WEBUI_VOICE_LANGUAGES[0]?.tag ?? "zh-CN" }; + + it("round-trips a stored object", () => { + const stored = JSON.stringify({ enabled: true, language: "en-US" }); + expect(parseWebuiVoiceSettings(stored)).toEqual({ enabled: true, language: "en-US" }); + }); + + /* localStorage outlives a downgrade of this build and can be edited by + * hand. A half-recognised value must not leave the mic permanently on. */ + it("falls back for a malformed or partial value", () => { + expect(parseWebuiVoiceSettings("{nope")).toEqual(defaults); + expect(parseWebuiVoiceSettings('{"enabled":true}')).toEqual(defaults); + expect(parseWebuiVoiceSettings('{"enabled":"yes","language":"en-US"}')).toEqual(defaults); + expect(parseWebuiVoiceSettings(null)).toEqual(defaults); + }); + + it("refuses a language it does not offer", () => { + expect(parseWebuiVoiceSettings('{"enabled":true,"language":"xx-YY"}').language).toBe(defaults.language); + }); +}); + +/* The page renders below. These close the loops a static render cannot: that + * the tab reaches it, and that the composer has somewhere to put a mic. A mic + * that no button drives is the same class of defect as the disabled tab. */ +describe("wiring", () => { + const source = readFileSync( + fileURLToPath(new URL("../../src/client/components/SettingsModal.tsx", import.meta.url)), + "utf8", + ); + const composer = readFileSync( + fileURLToPath(new URL("../../src/client/components/SessionComposer.tsx", import.meta.url)), + "utf8", + ); + + it("routes the voice tab to its own page", () => { + expect(source).toContain('{active === "voice" ? { + expect(source).not.toContain('key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true'); + }); + + it("gives the composer a control that starts listening", () => { + // The testid alone is not the feature: it sits inside the `voice.available` + // branch, and negative injection showed that a `{false ? …` around it left + // every assertion here green while removing the button from the composer + // entirely. Assert the guard and the control as one piece of source. + expect(composer).toMatch(/\{voice\.available \? \([\s\S]*?data-testid="composer-voice"/u); + }); + + /* The mic must not appear before the user has asked for the feature, and + * must not appear where the browser cannot listen. */ + it("holds the mic back until the feature is on and the browser can listen", () => { + const hook = readFileSync( + fileURLToPath(new URL("../../src/client/use-webui-voice.ts", import.meta.url)), + "utf8", + ); + expect(hook).toContain('available: settings.enabled && state.support === "supported"'); + }); + + /* The mirror that pushes the reducer's draft back into the composer compares + * it against what the user has typed. Unguarded, the untouched `""` of a + * fresh state differs from anything the user wrote, and the composer is + * cleared on the first keystroke. The guard is the whole fix, so it is the + * thing asserted here. */ + it("only pushes the reducer's draft back while a voice session owns it", () => { + const hook = readFileSync( + fileURLToPath(new URL("../../src/client/use-webui-voice.ts", import.meta.url)), + "utf8", + ); + expect(hook).toContain("if (!voiceTouchedRef.current || state.draft === draft) return;"); + }); +}); + +describe("the voice settings page", () => { + const render = (props: Record = {}): string => + renderToStaticMarkup( + createElement(WebuiVoiceSettings, { settings: { enabled: false, language: "zh-CN" }, support: "supported", ...props } as never), + ); + + it("says plainly when the browser cannot listen", () => { + const markup = render({ support: "unsupported" }); + expect(markup).toContain('data-testid="voice-unsupported"'); + /* A banner that does not say what to do about it is noise. */ + expect(markup).toContain("Chrome"); + }); + + it("offers the recogniser languages as a choice", () => { + const markup = render(); + for (const entry of WEBUI_VOICE_LANGUAGES) { + expect(markup).toContain(entry.tag); + } + }); + + it("marks the page so the composer and the test can find it", () => { + expect(render()).toContain('data-testid="settings-voice-page"'); + }); + + it("carries the stored settings key", () => { + expect(WEBUI_VOICE_SETTINGS_KEY.length).toBeGreaterThan(0); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index cf1362d7..dc645040 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3635,6 +3635,7 @@ "packages/webui/src/client/projection/transcript-scroll.ts", "packages/webui/src/client/projection/transcript-shape.ts", "packages/webui/src/client/projection/usage-settings.ts", + "packages/webui/src/client/projection/voice-state.ts", "packages/webui/src/client/projection/workspace-panel-state.ts", "packages/webui/src/client/projection/workspace-progress.ts", "packages/webui/src/client/projection/worktree-state.ts", @@ -3658,6 +3659,7 @@ "packages/webui/src/client/styles/transcript-widgets.css", "packages/webui/src/client/team-mode.ts", "packages/webui/src/client/transport.ts", + "packages/webui/src/client/use-webui-voice.ts", "packages/webui/src/client/value-readers.ts", "packages/webui/src/server/account-login.ts", "packages/webui/src/server/assembly.ts", @@ -3776,6 +3778,7 @@ "packages/webui/test/unit/transport-frame.test.ts", "packages/webui/test/unit/turn-navigator.test.tsx", "packages/webui/test/unit/vite-dev-proxy.test.ts", + "packages/webui/test/unit/voice-state.test.ts", "packages/webui/test/unit/webui-auth-context.test.ts", "packages/webui/test/unit/webui-boundary-check.test.ts", "packages/webui/test/unit/webui-checkin.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 5114f0b5..ca90772a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -281,6 +281,7 @@ "packages/webui/test/unit/worktree-state.test.ts", "packages/webui/test/unit/review-panel.test.tsx", "packages/webui/test/unit/shortcut-state.test.ts", + "packages/webui/test/unit/voice-state.test.ts", "packages/webui/test/unit/review-state.test.ts", "packages/webui/test/unit/webui-round3-acceptance.test.tsx", "packages/webui/test/unit/webui-w0-store-sequence.test.ts", diff --git a/test/webui-browser/settings-account-tab.spec.mjs b/test/webui-browser/settings-account-tab.spec.mjs index 65d5ce10..da664439 100644 --- a/test/webui-browser/settings-account-tab.spec.mjs +++ b/test/webui-browser/settings-account-tab.spec.mjs @@ -107,13 +107,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 two that - // remain have no content behind them, which is what the `disabled` gate is - // still for. - for (const key of ["voice", "connection"]) { + // 代码审查, 工作树, 快捷键 and 语音 left this list when their pages were + // built — they are real panels now, not empty panes behind a clickable + // label. What remains has no content behind it, which is what the `disabled` + // gate is still for. + for (const key of ["connection"]) { await expect(settingsNavItem(page, key)).toBeDisabled(); } // The app behind the modal is still mounted: the modal is a `document.body` From afaafbbc80b436b0b36f719b9fe06178286c56c7 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:19:52 +0800 Subject: [PATCH 3/4] fix(webui): accept a loopback origin in dev so the vite shell can connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dev-mode upgrade and API call was rejected with 403 Forbidden Origin, and the shell sat on "WebUI connection failed". Sessions and account state came back empty because nothing had ever connected: the websocket handshake never completed. `isAllowedOrigin` requires the origin's port to equal the bound port, which is correct for the packaged server — ADR 0004 has it serve the built client from the same listener, so same-origin is the right rule there. The Vite dev server necessarily listens on a different port (5173 against 8787), so every request the browser made carried an origin the check rejected. Measured, before this change: ws://127.0.0.1:8787/ws Origin http://127.0.0.1:8787 -> OPEN ws://127.0.0.1:5173/ws Origin http://127.0.0.1:5173 -> ERROR After: both OPEN. The port check is skipped only in dev, and only after the checks above it have already held — the origin must still be http or https and its host must still be `127.0.0.1` or `localhost`. A page on another machine still cannot reach the service. This is a port of another contributor's uncommitted work on this file, which I had to drop while syncing the base: their patch no longer applies, because PR #29 added 153 lines to `service.ts` and PR #32 moved the other two files in the same change set. The reasoning and the shape are theirs, carried over rather than rewritten; if they would rather land it themselves, this commit should be dropped in favour of theirs. Validation - pnpm typecheck:webui-full exit 0 - run-vitest-suite.mjs webui 10 failed | 1917 passed | 4 skipped The 10 failures are the unchanged pre-existing Windows baseline. The browser suite's own harness does not exercise the dev proxy path, so it neither caught this nor proves the fix; the handshake measurement above is what does. Not covered here: `vite.config.ts` and `build-webui-styles.mjs` carried related edits in the same dropped patch. Those address dev-server packaging rather than the handshake, and neither was needed to restore the connection. --- packages/webui/src/server/service.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/server/service.ts b/packages/webui/src/server/service.ts index 6a26018f..8e59b5ff 100644 --- a/packages/webui/src/server/service.ts +++ b/packages/webui/src/server/service.ts @@ -330,7 +330,7 @@ export class WebuiService { } if ( requestOrigin && - !isAllowedOrigin(requestOrigin, this.host, this.bound?.info.tcpPort) + !isAllowedOrigin(requestOrigin, this.host, this.bound?.info.tcpPort, this.dev) ) { rejectHttp(response, 403, "Forbidden Origin"); return; @@ -756,7 +756,7 @@ export class WebuiService { } if ( requestOrigin && - !isAllowedOrigin(requestOrigin, this.host, this.bound?.info.tcpPort) + !isAllowedOrigin(requestOrigin, this.host, this.bound?.info.tcpPort, this.dev) ) { rejectUpgrade(socket, 403, "Forbidden Origin"); return; @@ -1117,7 +1117,12 @@ function isLoopbackBindAddress(host: string): boolean { return isLoopbackHost(stripped); } -function isAllowedOrigin(origin: string, host: string, port?: number): boolean { +function isAllowedOrigin( + origin: string, + host: string, + port?: number, + dev = false, +): boolean { let parsed: URL; try { parsed = new URL(origin); @@ -1129,6 +1134,14 @@ function isAllowedOrigin(origin: string, host: string, port?: number): boolean { const hostname = parsed.hostname.toLowerCase(); if (hostname !== "127.0.0.1" && hostname !== "localhost") return false; if (port === undefined) return true; + // ADR 0004 pins the packaged server to same-origin requests: it serves the + // built client from this same listener, so the origin port has to be the + // bound port. The Vite dev server necessarily listens on a different port, + // which left every dev-mode upgrade and API call rejected with 403 and the + // shell stuck on "WebUI connection failed". In dev, any loopback origin is + // accepted - the loopback-host and http/https checks above still hold, so a + // page on another machine still cannot reach the service. + if (dev) return true; const portNumber = parsed.port ? Number(parsed.port) : defaultPortForProtocol(protocol); From 4e8c8702b7847b1a52f19dcb9239a8dee8eeda52 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:25:12 +0800 Subject: [PATCH 4/4] Fix the voice switch taking effect only after a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer reads its voice settings once, in the mount effect, and nothing re-runs that effect when the settings page writes. So turning voice on left the switch reading "on" in settings and no mic button in the composer until the user reloaded the page — the same "the structure is right, the thing does not work" failure this row started as, caught on the first manual pass. The two ends only agree if the settings page writes through a writer that also tells its readers, and the composer subscribes to that writer: - `voice-state.ts` grows a module-level `subscribeWebuiVoiceSettings` and a `writeWebuiVoiceSettings` that persists and then notifies. A subscription rather than a DOM event: both ends are React state in the same bundle, so an event bus would buy nothing here. - `use-webui-voice.ts` returns the subscription from the mount effect. It is returned, not merely called, so React tears the listener down on unmount. - `SettingsModal.tsx` writes through `writeWebuiVoiceSettings` instead of calling `localStorage.setItem` directly. A bare `setItem` persists the value and tells nobody, which is the bug itself. ## Test evidence 31 tests in `voice-state.test.ts` (was 27), all green. The four new ones cover the join between the two ends: - two behavioural tests on the pub/sub itself — a write reaches every reader, a write after unsubscribing does not, and the value written is the value stored - two source assertions pinning the wiring. These are source assertions for the same reason the rest of that block is: the subscription lives in a hook effect, the webui suite runs in `environment: "node"` with no jsdom and no test renderer, and `renderToStaticMarkup` does not run effects. They exist because the projection tests provably cannot reach them — deleting `return subscribeWebuiVoiceSettings(setSettings);` leaves all 31 green. Negative injection: 30 mutations, 0 survivors (`neg-inject-voice.mjs`). The nine added for this fix include the three that only the wiring assertions can kill — the composer not subscribing, subscribing without cleanup, and subscribing from the wrong effect. Gates: `pnpm check:source` 4758 files clean, `pnpm typecheck:webui-full` clean, full webui suite 10 failed / 1921 passed / 4 skipped. The 10 failures are the pre-existing Windows baseline (path-separator assertions in `webui-boundary-check`, `webui-design-tokens`, `profile-files`, plus one `webui-service` shutdown timeout); the count is unchanged from the baseline at 1917 passed, and the delta is exactly these four new tests. Verified in a real browser at 127.0.0.1:5173 — opening Settings → Voice, flipping the switch, and returning to the app without a reload puts `[data-testid="composer-voice"]` in the composer, between the `+` button and the authorisation-mode button. --- .../src/client/components/SettingsModal.tsx | 3 +- .../src/client/projection/voice-state.ts | 25 ++++++ packages/webui/src/client/use-webui-voice.ts | 4 + packages/webui/test/unit/voice-state.test.ts | 77 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx index ae4faaaf..c5d3848e 100644 --- a/packages/webui/src/client/components/SettingsModal.tsx +++ b/packages/webui/src/client/components/SettingsModal.tsx @@ -24,6 +24,7 @@ import { WEBUI_VOICE_SETTINGS_KEY, parseWebuiVoiceSettings, resolveWebuiVoiceSupport, + writeWebuiVoiceSettings, type WebuiVoiceSettings as WebuiVoiceSettingsValue, type WebuiVoiceSupport, } from "../projection/voice-state.js"; @@ -508,7 +509,7 @@ function SettingsVoicePage(): ReactElement { const write = useCallback((next: WebuiVoiceSettingsValue) => { setSettings(next); - localStorage.setItem(WEBUI_VOICE_SETTINGS_KEY, JSON.stringify(next)); + writeWebuiVoiceSettings(next); }, []); return void>(); + +/** + * The composer reads its settings when it mounts. Nothing re-runs that effect + * when the settings page writes, so without this the switch says "on" in one + * place and the mic stays absent in the other until a reload. + * + * A module-level subscription rather than a DOM event: both ends are React + * state in the same bundle, and an event bus would buy nothing here. + */ +export function subscribeWebuiVoiceSettings(listener: (next: WebuiVoiceSettings) => void): () => void { + SETTINGS_LISTENERS.add(listener); + return () => { + SETTINGS_LISTENERS.delete(listener); + }; +} + +/** The one place that writes the setting. Persists it, then tells the readers. */ +export function writeWebuiVoiceSettings(next: WebuiVoiceSettings): void { + if (typeof localStorage !== "undefined") { + localStorage.setItem(WEBUI_VOICE_SETTINGS_KEY, JSON.stringify(next)); + } + for (const listener of SETTINGS_LISTENERS) listener(next); +} diff --git a/packages/webui/src/client/use-webui-voice.ts b/packages/webui/src/client/use-webui-voice.ts index 45ae7760..a53b27ce 100644 --- a/packages/webui/src/client/use-webui-voice.ts +++ b/packages/webui/src/client/use-webui-voice.ts @@ -18,6 +18,7 @@ import { parseWebuiVoiceSettings, reduceWebuiVoiceState, resolveWebuiVoiceSupport, + subscribeWebuiVoiceSettings, type WebuiVoiceSettings, type WebuiVoiceState, } from "./projection/voice-state.js"; @@ -85,6 +86,9 @@ export function useWebuiVoice(options: { type: "support-resolved", support: speechRecognitionCtor() ? "supported" : "unsupported", })); + // The settings page can flip voice on at any time, including after this + // component mounted. Reading once left the mic missing until a reload. + return subscribeWebuiVoiceSettings(setSettings); }, []); // The reducer decides what the draft becomes; this only pushes that out, and diff --git a/packages/webui/test/unit/voice-state.test.ts b/packages/webui/test/unit/voice-state.test.ts index a0f598a0..9ed02d35 100644 --- a/packages/webui/test/unit/voice-state.test.ts +++ b/packages/webui/test/unit/voice-state.test.ts @@ -36,6 +36,8 @@ import { reduceWebuiVoiceState, resolveWebuiVoiceSupport, parseWebuiVoiceSettings, + subscribeWebuiVoiceSettings, + writeWebuiVoiceSettings, type WebuiVoiceSettings as WebuiVoiceSettingsValue, } from "../../src/client/projection/voice-state.js"; @@ -178,6 +180,41 @@ describe("settings that survive a reload", () => { }); }); +describe("a switch flipped after the page loaded", () => { + /* The composer reads the setting when it mounts. Without a way to tell it + * the setting changed, turning voice on in settings and then walking back to + * the composer leaves the button missing until a reload — the setting page + * says "on" and the composer says "off", which is the same "structure is + * right, the thing does not work" failure this row started as. */ + it("tells every reader when the stored settings change", () => { + const seen: WebuiVoiceSettingsValue[] = []; + const unsubscribe = subscribeWebuiVoiceSettings((next) => seen.push(next)); + + writeWebuiVoiceSettings({ enabled: true, language: "en-US" }); + unsubscribe(); + // After unsubscribing, a later write must not reach this listener. + writeWebuiVoiceSettings({ enabled: false, language: "ja-JP" }); + + expect(seen).toEqual([{ enabled: true, language: "en-US" }]); + }); + + it("persists what it tells the readers", () => { + // The suite runs in node, where there is no localStorage at all. + const calls: Array<[string, string]> = []; + const scope = globalThis as Record; + const had = "localStorage" in scope; + const previous = scope.localStorage; + scope.localStorage = { setItem: (key: string, value: string) => calls.push([key, value]) }; + try { + writeWebuiVoiceSettings({ enabled: true, language: "en-US" }); + } finally { + if (had) scope.localStorage = previous; + else delete scope.localStorage; + } + expect(calls).toEqual([[WEBUI_VOICE_SETTINGS_KEY, JSON.stringify({ enabled: true, language: "en-US" })]]); + }); +}); + /* The page renders below. These close the loops a static render cannot: that * the tab reaches it, and that the composer has somewhere to put a mic. A mic * that no button drives is the same class of defect as the disabled tab. */ @@ -230,6 +267,46 @@ describe("wiring", () => { ); expect(hook).toContain("if (!voiceTouchedRef.current || state.draft === draft) return;"); }); + + /* The two ends of that switch. Turning voice on is a write from the settings + * page; the mic showing up is a read in a composer that mounted long before. + * Nothing re-runs that composer's mount effect when the settings page + * changes, so the two ends only agree if the page writes through the + * notifying writer AND the composer subscribes. Either half on its own leaves + * the user with a switch reading "on" over a composer with no mic — which is + * precisely the bug this row shipped the first time round. + * + * Source assertions, for the reason the rest of this block is: the + * subscription lives in a hook effect, the webui suite runs in + * `environment: "node"` with no jsdom or test renderer, and + * `renderToStaticMarkup` does not run effects. The behaviour of both halves + * is proved above; what is left is the join between them. */ + it("has the composer subscribe inside the effect that reads the setting", () => { + const hook = readFileSync( + fileURLToPath(new URL("../../src/client/use-webui-voice.ts", import.meta.url)), + "utf8", + ); + const start = hook.indexOf("setSettings(parseWebuiVoiceSettings(localStorage.getItem("); + expect(start, "the composer no longer reads the stored setting on mount").toBeGreaterThanOrEqual(0); + const end = hook.indexOf("}, []);", start); + expect(end, "the mount effect no longer closes on an empty dependency array").toBeGreaterThan(start); + // Returned, not merely called. The returned cleanup is what React runs on + // unmount, and without it every later write pokes a component long gone. + expect(hook.slice(start, end)).toContain("return subscribeWebuiVoiceSettings(setSettings);"); + }); + + it("has the settings page write through the notifying writer", () => { + const start = source.indexOf("const write = useCallback((next: WebuiVoiceSettingsValue) => {"); + expect(start, "the voice settings page's write callback moved").toBeGreaterThanOrEqual(0); + const end = source.indexOf("}, []);", start); + expect(end, "the voice settings page's write callback no longer closes on an empty dependency array").toBeGreaterThan(start); + const body = source.slice(start, end); + expect(body).toContain("writeWebuiVoiceSettings(next)"); + // A bare setItem persists the value and tells nobody. That is the bug + // itself, not a near miss of it: the switch looks right and the mic stays + // missing until a reload. + expect(body).not.toContain("localStorage.setItem"); + }); }); describe("the voice settings page", () => {