From 739b2535628e7fdabd79b786098eb26046637eb5 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Mon, 9 Nov 2026 16:35:13 +0530 Subject: [PATCH 1/2] feat(web): edit the dictation shortcut inline in Voice settings --- .../KeybindingCommandControl.browser.test.tsx | 188 ++++++++++++++++++ .../settings/KeybindingsSettings.tsx | 166 +++++++++++----- .../settings/VoiceSettingsPanel.tsx | 40 ++-- .../useDictationShortcut.browser.test.tsx | 26 ++- apps/web/src/speech/useDictationShortcut.ts | 2 + 5 files changed, 354 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx diff --git a/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx b/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx new file mode 100644 index 0000000000000..967a6692d2c2d --- /dev/null +++ b/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx @@ -0,0 +1,188 @@ +import { + EnvironmentId, + type KeybindingRule, + type ServerUpsertKeybindingInput, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { RegistryProvider } from "@effect/atom-react"; +import type { ReactNode } from "react"; +import { AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { + readEnvironmentScope, + useEnvironmentScope, + useEnvironmentsWithScope, +} from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; +import { KeybindingCommandControl } from "./KeybindingsSettings"; +import { useSettingsScope, useOptionalSettingsScope } from "./SettingsScopeContext"; + +vi.mock(import("../../state/session"), { spy: true }); +vi.mock(import("../../state/use-atom-command"), { spy: true }); +vi.mock(import("./SettingsScopeContext"), { spy: true }); + +function setup(rules: readonly (readonly KeybindingRule[] | null)[] = [[]]) { + const environments = rules.map((bindings, index) => ({ + environmentId: EnvironmentId.make(`dictation-${index}`), + label: `Environment ${index}`, + serverConfig: + bindings === null + ? null + : { + keybindings: compileResolvedKeybindingsConfig(bindings), + keybindingsConfigPath: null, + availableEditors: [], + }, + })); + const scope = { + environment: environments[0] ?? null, + connectedEnvironments: environments, + } as unknown as NonNullable>; + vi.mocked(useOptionalSettingsScope).mockReturnValue(scope); + vi.mocked(useSettingsScope).mockReturnValue(scope); + vi.mocked(useEnvironmentScope).mockReturnValue(true); + vi.mocked(useEnvironmentsWithScope).mockReturnValue( + new Set(environments.map((environment) => environment.environmentId)), + ); + vi.mocked(readEnvironmentScope).mockReturnValue(true); + const save = vi + .fn< + (input: { + environmentId: EnvironmentId; + input: ServerUpsertKeybindingInput; + }) => Promise | AsyncResult.Failure> + >() + .mockResolvedValue(AsyncResult.success(undefined)); + vi.mocked(useAtomCommand).mockReturnValue(save as ReturnType); + return { environments, save, scope }; +} + +function Control({ children }: { children: ReactNode }) { + return ( + + <> + + {children} + + + ); +} + +const dictation: KeybindingRule = { + command: "composer.dictation", + key: "mod+alt+d", + when: "!terminalFocus", +}; +const editName = /^Edit shortcut for Composer: Start or Finish Dictation:/; +const recorded = /Mac/i.test(navigator.platform) ? "{Meta>}k{/Meta}" : "{Control>}k{/Control}"; + +it("replaces the saved rule on each selected environment and keeps its condition", async () => { + const { environments, save, scope } = setup([[dictation], [dictation]]); + await using screen = await render(); + await screen.getByRole("button", { name: editName }).click(); + await userEvent.keyboard(recorded); + await expect + .element(screen.getByLabelText("Conflicts with Command Palette: Toggle.")) + .toBeVisible(); + expect(save).not.toHaveBeenCalled(); + await screen.getByRole("button", { name: "Save", exact: true }).click(); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + expect(save.mock.calls.map(([input]) => input)).toEqual( + environments.map((environment) => ({ + environmentId: environment.environmentId, + input: { command: dictation.command, key: "mod+k", when: dictation.when, replace: dictation }, + })), + ); + vi.mocked(useOptionalSettingsScope).mockReturnValue({ + ...scope, + connectedEnvironments: scope.connectedEnvironments.map((environment) => ({ + ...environment, + serverConfig: { + ...environment.serverConfig!, + keybindings: compileResolvedKeybindingsConfig([{ ...dictation, key: "mod+k" }]), + }, + })), + }); + await screen.rerender(); + await expect + .element( + screen.getByRole("button", { + name: /Edit shortcut for Composer: Start or Finish Dictation: Ctrl\+K/, + }), + ) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Save", exact: true })) + .not.toBeInTheDocument(); +}); + +it("discards an inline shortcut draft on Escape without changing the saved binding", async () => { + const { save } = setup([[dictation]]); + await using screen = await render({null}); + await screen.getByRole("button", { name: editName }).click(); + await userEvent.keyboard(recorded); + await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(screen.getByRole("button", { name: editName })).toHaveFocus(); + await expect + .element(screen.getByRole("button", { name: "Save", exact: true })) + .not.toBeInTheDocument(); + expect(save).not.toHaveBeenCalled(); +}); + +it.each([ + { name: "Mixed shortcuts", rules: [[dictation], [{ ...dictation, key: "mod+alt+v" }]] }, + { name: "Multiple shortcuts", rules: [[dictation, { ...dictation, key: "mod+alt+v" }]] }, + { name: "Unloaded settings", rules: [[], null] }, +])("opens detailed editing for $name", async ({ rules }) => { + const { save } = setup(rules); + await using screen = await render({null}); + await expect + .element(screen.getByRole("link", { name: "Edit in Keybindings" })) + .toHaveAttribute("href", "/settings/keybindings#keybinding-composer.dictation"); + expect(save).not.toHaveBeenCalled(); +}); + +it("blocks editing without settings permission and rechecks permission when saving", async () => { + const { save } = setup(); + vi.mocked(useEnvironmentsWithScope).mockReturnValue(new Set()); + await using screen = await render( + + + , + ); + expect(screen.container.querySelector("[inert]")).not.toBeNull(); + await expect.element(screen.getByRole("button", { name: "Recording mode" })).toBeEnabled(); + vi.mocked(useEnvironmentsWithScope).mockReturnValue(new Set([EnvironmentId.make("dictation-0")])); + await screen.rerender({null}); + await screen.getByRole("button", { name: editName }).click(); + await userEvent.keyboard(recorded); + vi.mocked(readEnvironmentScope).mockReturnValue(false); + await screen.getByRole("button", { name: "Save", exact: true }).click(); + expect(save).not.toHaveBeenCalled(); +}); + +it("keeps the draft available after a failed save", async () => { + const { save } = setup(); + save.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Disconnected")))); + await using screen = await render({null}); + using notification = vi.spyOn(toastManager, "add"); + await screen.getByRole("button", { name: editName }).click(); + await userEvent.keyboard(recorded); + await screen.getByRole("button", { name: "Save", exact: true }).click(); + await vi.waitFor(() => expect(notification).toHaveBeenCalledOnce()); + expect(notification).toHaveBeenCalledWith( + expect.objectContaining({ title: "Unable to save keybinding", type: "error" }), + ); + await expect + .element( + screen.getByRole("textbox", { name: "Keybinding for Composer: Start or Finish Dictation" }), + ) + .toHaveValue("mod+k"); + await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); +}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index cb00bca7e9cae..0f798f532ccc1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -11,7 +11,7 @@ import { TriangleAlertIcon, XIcon, } from "lucide-react"; -import { useLocation } from "@tanstack/react-router"; +import { Link, useLocation } from "@tanstack/react-router"; import { type KeyboardEvent, type ReactNode, @@ -40,7 +40,7 @@ import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; import { serverEnvironment } from "../../state/server"; -import { useSettingsScope } from "./SettingsScopeContext"; +import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -1312,21 +1312,128 @@ function useKeybindingCatalog() { }; } +function useScopedKeybindingSave( + connectedEnvironments: ReturnType["connectedEnvironments"], + onSaved?: () => void, +) { + const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope); + const canWriteSettings = + connectedEnvironments.length > 0 && + connectedEnvironments.every((target) => writableIds.has(target.environmentId)); + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { + reportFailure: false, + }); + const [savingCommand, setSavingCommand] = useState(null); + const saveKeybinding = (input: ServerUpsertKeybindingInput) => { + if ( + connectedEnvironments.length === 0 || + !connectedEnvironments.every((target) => + readEnvironmentScope(target.environmentId, AuthSettingsWriteScope), + ) + ) + return; + setSavingCommand(input.command); + const payload: ServerUpsertKeybindingInput = { + command: input.command, + key: input.key.trim(), + ...(input.when?.trim() ? { when: input.when.trim() } : {}), + ...(input.replace ? { replace: input.replace } : {}), + }; + void (async () => { + const results = await Promise.all( + connectedEnvironments.map((target) => + upsertKeybinding({ environmentId: target.environmentId, input: payload }), + ), + ); + setSavingCommand(null); + const failed = results.find((result) => result._tag === "Failure"); + if (!failed) { + onSaved?.(); + return; + } + if (!isAtomCommandInterrupted(failed)) { + const error = squashAtomCommandFailure(failed); + toastManager.add({ + title: "Unable to save keybinding", + description: error instanceof Error ? error.message : "The keybinding was not saved.", + type: "error", + }); + } + })(); + }; + + return { savingCommand, setSavingCommand, saveKeybinding, canWriteSettings }; +} + +function InlineKeybindingEditor({ + row, + allRows, + isSaving, + onSave, +}: { + row: KeybindingRow; + allRows: readonly KeybindingRow[]; + isSaving: boolean; + onSave: (input: ServerUpsertKeybindingInput) => void; +}) { + const editor = useKeybindingRowEditor({ row, allRows, onSave }); + return ( +
+ + +
+ ); +} + +export function KeybindingCommandControl({ command }: { command: KeybindingCommand }) { + const scope = useOptionalSettingsScope(); + const environments = scope?.connectedEnvironments ?? []; + const { savingCommand, saveKeybinding, canWriteSettings } = useScopedKeybindingSave(environments); + const catalogs = environments.map((environment) => + environment.serverConfig + ? buildKeybindingRows(mergeWithDefaultKeybindings(environment.serverConfig.keybindings)) + : null, + ); + const allRows = catalogs.flatMap((rows) => rows ?? []); + const rows = catalogs[0]?.filter((row) => row.command === command); + const row = rows?.[0]; + const simple = + row && + rows.length === 1 && + catalogs.every((rows) => { + const matches = rows?.filter((candidate) => candidate.command === command); + return matches?.length === 1 && matches[0]?.id === row.id; + }); + if (!scope) return null; + return simple ? ( +
+ environment.environmentId).join(",")}:${row.id}`} + row={row} + allRows={allRows} + isSaving={savingCommand === command} + onSave={saveKeybinding} + /> +
+ ) : ( + + ); +} + export function KeybindingsSettingsPanel() { const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); const canOpenKeybindingsFile = useEnvironmentScope( primaryEnvironment?.environmentId ?? null, AuthOrchestrationOperateScope, ); - const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope); - const canWriteSettings = - connectedEnvironments.length > 0 && - connectedEnvironments.every((target) => writableIds.has(target.environmentId)); const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); @@ -1336,8 +1443,9 @@ export function KeybindingsSettingsPanel() { ); const [query, setQuery] = useState(""); const searchInputRef = useRef(null); - const [savingCommand, setSavingCommand] = useState(null); const [isAddingBinding, setIsAddingBinding] = useState(false); + const { savingCommand, setSavingCommand, saveKeybinding, canWriteSettings } = + useScopedKeybindingSave(connectedEnvironments, () => setIsAddingBinding(false)); const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId); @@ -1373,44 +1481,6 @@ export function KeybindingsSettingsPanel() { })(); }; - const saveKeybinding = (input: ServerUpsertKeybindingInput) => { - if ( - !primaryEnvironment || - !connectedEnvironments.every((target) => - readEnvironmentScope(target.environmentId, AuthSettingsWriteScope), - ) - ) - return; - setSavingCommand(input.command); - const payload: ServerUpsertKeybindingInput = { - command: input.command, - key: input.key.trim(), - ...(input.when?.trim() ? { when: input.when.trim() } : {}), - ...(input.replace ? { replace: input.replace } : {}), - }; - void (async () => { - const results = await Promise.all( - connectedEnvironments.map((target) => - upsertKeybinding({ environmentId: target.environmentId, input: payload }), - ), - ); - setSavingCommand(null); - const failed = results.find((result) => result._tag === "Failure"); - if (!failed) { - setIsAddingBinding(false); - return; - } - if (!isAtomCommandInterrupted(failed)) { - const error = squashAtomCommandFailure(failed); - toastManager.add({ - title: "Unable to save keybinding", - description: error instanceof Error ? error.message : "The keybinding was not saved.", - type: "error", - }); - } - })(); - }; - const removeKeybinding = (row: KeybindingRow) => { if ( !primaryEnvironment || diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index 488556e8f40fe..1aebe80e676de 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -104,6 +104,7 @@ import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; import { MicrophoneTest } from "./MicrophoneTest"; import { CloudVoiceConfiguration } from "./CloudVoiceConfiguration"; +import { KeybindingCommandControl } from "./KeybindingsSettings"; import { LanguageRow, ModelBenchmarks, orderSpeechLanguages } from "./SpeechModelComparison"; import { languageLabel, SpeechLanguageSelect } from "./SpeechLanguageSelect"; import { filterSpeechModels, type SpeechModelSort } from "./speechModelBrowser"; @@ -1258,24 +1259,27 @@ export function VoiceSettingsPanel() { title="Dictation shortcut" description="How the dictation shortcut starts and stops recording." control={ - +
+ + +
} /> diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx index 8cd17f4f368dc..e3a2779051a22 100644 --- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx +++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx @@ -16,8 +16,8 @@ function Probe({ speech }: { speech: Parameters[0][ return null; } -const press = (repeat = false) => - window.dispatchEvent( +const press = (repeat = false, target: EventTarget = window) => + target.dispatchEvent( new KeyboardEvent("keydown", { key: "D", code: "KeyD", @@ -25,6 +25,7 @@ const press = (repeat = false) => metaKey: /Mac/i.test(navigator.platform), shiftKey: true, cancelable: true, + bubbles: true, repeat, }), ); @@ -46,6 +47,27 @@ it("leaves the browser shortcut alone when dictation is off", async () => { expect(speech.start).not.toHaveBeenCalled(); }); +it("leaves shortcut recording keys and Escape to the recorder", async () => { + const speech = actions(true); + await using screen = await render( + <> + + + , + ); + const field = screen.getByRole("textbox", { name: "Record shortcut" }).element(); + expect(press(false, field)).toBe(true); + expect(speech.start).not.toHaveBeenCalled(); + await screen.rerender( + <> + + + , + ); + field.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(speech.cancel).not.toHaveBeenCalled(); +}); + it("stops on a new press when the OS omitted keyup, but ignores autorepeat", async () => { const speech = actions(true); await using screen = await render(); diff --git a/apps/web/src/speech/useDictationShortcut.ts b/apps/web/src/speech/useDictationShortcut.ts index 6acbe5fac1ee3..3dcb1d9da48c5 100644 --- a/apps/web/src/speech/useDictationShortcut.ts +++ b/apps/web/src/speech/useDictationShortcut.ts @@ -98,6 +98,8 @@ export function useDictationShortcut(input: ShortcutInput) { const onKeyDown = (event: KeyboardEvent) => { if (!ownsShortcut(id.current)) return; if (event.isComposing || event.keyCode === 229) return; + if (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]")) + return; const { speech } = current.current; if (event.defaultPrevented || isCommandPaletteOpen()) return; if ( From e6248de0c996ef624824baecc9d3330d9c2ab1e6 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Mon, 9 Nov 2026 16:47:24 +0530 Subject: [PATCH 2/2] test(web): accept macOS dictation shortcut labels --- .../settings/KeybindingCommandControl.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx b/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx index 967a6692d2c2d..e75a1fc314dd8 100644 --- a/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx +++ b/apps/web/src/components/settings/KeybindingCommandControl.browser.test.tsx @@ -112,7 +112,7 @@ it("replaces the saved rule on each selected environment and keeps its condition await expect .element( screen.getByRole("button", { - name: /Edit shortcut for Composer: Start or Finish Dictation: Ctrl\+K/, + name: /Edit shortcut for Composer: Start or Finish Dictation: (Ctrl\+K|⌘K)/, }), ) .toBeVisible();