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}
;
}
+/* 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
;
+}
+
+/** 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/projection/voice-state.ts b/packages/webui/src/client/projection/voice-state.ts
new file mode 100644
index 00000000..444262bc
--- /dev/null
+++ b/packages/webui/src/client/projection/voice-state.ts
@@ -0,0 +1,181 @@
+/* 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;
+ }
+}
+
+const SETTINGS_LISTENERS = new Set<(next: WebuiVoiceSettings) => 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/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/src/client/use-webui-voice.ts b/packages/webui/src/client/use-webui-voice.ts
new file mode 100644
index 00000000..a53b27ce
--- /dev/null
+++ b/packages/webui/src/client/use-webui-voice.ts
@@ -0,0 +1,168 @@
+/* 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,
+ subscribeWebuiVoiceSettings,
+ 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 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
+ // 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/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);
diff --git a/packages/webui/test/unit/settings-modal.test.tsx b/packages/webui/test/unit/settings-modal.test.tsx
index 6051ebc7..5d5fc2e0 100644
--- a/packages/webui/test/unit/settings-modal.test.tsx
+++ b/packages/webui/test/unit/settings-modal.test.tsx
@@ -90,17 +90,19 @@ 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), `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", "shortcuts", "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/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}`);
+ }
+ });
+
+ /* The reason the page exists is that a key already in use must not silently
+ * shadow the other command. */
+ it("names the command a chosen key is already taken by", () => {
+ 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/packages/webui/test/unit/voice-state.test.ts b/packages/webui/test/unit/voice-state.test.ts
new file mode 100644
index 00000000..9ed02d35
--- /dev/null
+++ b/packages/webui/test/unit/voice-state.test.ts
@@ -0,0 +1,339 @@
+/* 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,
+ subscribeWebuiVoiceSettings,
+ writeWebuiVoiceSettings,
+ 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);
+ });
+});
+
+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. */
+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;");
+ });
+
+ /* 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", () => {
+ 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 7d13fcd2..dc645040 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",
@@ -3634,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",
@@ -3657,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",
@@ -3759,6 +3762,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",
@@ -3774,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 e19d9583..ca90772a 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -280,6 +280,8 @@
"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/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 e7916d1f..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 three that
- // remain have no content behind them, which is what the `disabled` gate is
- // still for.
- for (const key of ["voice", "shortcuts", "connection"]) {
+ // 代码审查, 工作树, 快捷键 and 语音 left this list when their pages were
+ // built — they are real panels now, not empty panes behind a clickable
+ // label. 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`