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 !== "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 === "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 { @@ -440,6 +460,194 @@ export function WebuiReviewWorkspacePicker({ workspaces, selected, loading, erro ; } +/* 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); + writeWebuiVoiceSettings(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 + * 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}

+
    + {WEBUI_SHORTCUT_COMMANDS.filter((command) => command.group === group).map((command) => { + const isOverridden = overridden.has(command.id); + return
  • + {command.label} + {editing === command.id ? ( + 按下新的组合键,Esc 取消 + ) : ( + + )} + {isOverridden ? ( + + ) : null} +
  • ; + })} +
+
)} + {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/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} { + 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`