From 26c48bbc0a9ddc23acdd20a733fe209d423e848a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 03:14:17 -0700 Subject: [PATCH] fix(web): action shortcuts recorded in the browser now save (#19365) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 3 - .../useProjectScriptSettings.test.tsx | 147 ++++++++++++++++++ .../settings/useProjectScriptSettings.ts | 4 +- 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/settings/useProjectScriptSettings.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 712e4b9bb61b0abaadd1c035f9e79e805c0047f6..acd68260b9ad5bcc41c382f38c22e29a60bafdc0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5172,7 +5172,6 @@ export default function ChatView(props: ChatViewProps) { (script) => commandForProjectScript(script.id) === input.keybindingCommand, ); const changesKeybinding = - isElectron && input.keybinding !== undefined && (input.keybinding?.trim() || null) !== previousKeybinding && (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); @@ -5234,8 +5233,6 @@ export default function ChatView(props: ChatViewProps) { ); } - if (!isElectron) return updateResult; - const scriptId = input.keybindingCommand ? projectScriptIdFromCommand(input.keybindingCommand) : null; diff --git a/apps/web/src/components/settings/useProjectScriptSettings.test.tsx b/apps/web/src/components/settings/useProjectScriptSettings.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..721ce6d3474d61e9feba3706ee7cdd242ad8bcd7 --- /dev/null +++ b/apps/web/src/components/settings/useProjectScriptSettings.test.tsx @@ -0,0 +1,147 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { NewProjectScriptInput } from "../projectScriptEditor"; + +const commands = vi.hoisted(() => ({ + updateSettings: vi.fn(), + upsertKeybinding: vi.fn(), + removeKeybinding: vi.fn(), +})); + +// A browser client: the shortcut must still reach the environment's keybindings. +vi.mock("../../env", () => ({ isElectron: false })); +vi.mock("../../state/session", () => ({ readEnvironmentScope: () => true })); +vi.mock("../../state/entities", () => ({ useProjects: () => [] })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + updateSettings: "updateSettings", + upsertKeybinding: "upsertKeybinding", + removeKeybinding: "removeKeybinding", + }, +})); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: keyof typeof commands) => commands[command], +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { useProjectScriptSettings } from "./useProjectScriptSettings"; + +const environmentId = EnvironmentId.make("remote"); +const lint: ProjectScript = { + id: "lint", + name: "Lint", + command: "vp lint", + icon: "lint", + runOnWorktreeCreate: false, +}; +const lintBinding: ResolvedKeybindingsConfig = [ + { + command: "script.lint.run", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + }, +]; +const input = (keybinding: string | null): NewProjectScriptInput => ({ + name: lint.name, + command: lint.command, + icon: lint.icon, + runOnWorktreeCreate: false, + waitForSetup: false, + runOnSettle: false, + keybinding, + previewUrl: null, + autoOpenPreview: false, +}); + +type ProjectScriptSettings = ReturnType; + +function Probe(props: { + keybindings: ResolvedKeybindingsConfig; + onResult: (settings: ProjectScriptSettings) => void; +}) { + props.onResult( + useProjectScriptSettings([ + { + environmentId, + settings: DEFAULT_SERVER_SETTINGS, + keybindings: props.keybindings, + project: { id: ProjectId.make("project"), scripts: [lint] }, + }, + ]), + ); + return null; +} + +let renderer: ReactTestRenderer | undefined; + +function renderSettings(keybindings: ResolvedKeybindingsConfig) { + let settings: ProjectScriptSettings | undefined; + act(() => { + renderer = create( (settings = next)} />); + }); + return settings!; +} + +async function submitInAct(submit: ProjectScriptSettings["submit"], keybinding: string | null) { + let result: Awaited> | undefined; + await act(async () => { + result = await submit(lint.id, input(keybinding)); + }); + return result!; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + for (const command of Object.values(commands)) { + command.mockResolvedValue(AsyncResult.success(undefined)); + } +}); + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("useProjectScriptSettings in a browser client", () => { + it("saves a recorded action shortcut", async () => { + const { submit } = renderSettings([]); + + const result = await submitInAct(submit, "mod+shift+k"); + + expect(result._tag).toBe("Success"); + expect(commands.upsertKeybinding).toHaveBeenCalledWith({ + environmentId, + input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }), + }); + }); + + it("removes a cleared action shortcut", async () => { + const { submit } = renderSettings(lintBinding); + + const result = await submitInAct(submit, null); + + expect(result._tag).toBe("Success"); + expect(commands.removeKeybinding).toHaveBeenCalledWith({ + environmentId, + input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }), + }); + expect(commands.upsertKeybinding).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/useProjectScriptSettings.ts b/apps/web/src/components/settings/useProjectScriptSettings.ts index 16be0901bd9ff280ec13bcc9177eb5ea3ac041cc..db8ff499322a96311dfc947d739253a03b59d9f5 100644 --- a/apps/web/src/components/settings/useProjectScriptSettings.ts +++ b/apps/web/src/components/settings/useProjectScriptSettings.ts @@ -19,7 +19,6 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { isElectron } from "../../env"; import { decodeProjectScriptKeybindingRule, keybindingValueForCommand, @@ -50,7 +49,7 @@ function reportScriptFailure(result: AtomCommandResult) { /** * Edits the action list on every target: the environment default when there is - * no project, else that project's override entry. Shortcuts follow on desktop. + * no project, else that project's override entry, then saves changed shortcuts. */ export function useProjectScriptSettings( targets: readonly { @@ -129,7 +128,6 @@ export function useProjectScriptSettings( }, }); if (result._tag === "Failure") return reportScriptFailure(result); - if (!isElectron) continue; const changedIds = scriptId ? [scriptId] : current -- 2.54.0