authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 03:14:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 03:14:17-07:00
log26c48bbc0a9ddc23acdd20a733fe209d423e848a
tree88b88a7f8443c5900d8e42a3f2df95edf3b580c0
parent40d43a19670dd059f8d5705f801db7208620a2df
signature Signed by PGP key B5690EEEBB952194

fix(web): action shortcuts recorded in the browser now save (#19365)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 148 insertions(+), 6 deletions(-)

apps/web/src/components/ChatView.tsx-3
......@@ -5172,7 +5172,6 @@ export default function ChatView(props: ChatViewProps) {
51725172 (script) => commandForProjectScript(script.id) === input.keybindingCommand,
51735173 );
51745174 const changesKeybinding =
5175 isElectron &&
51765175 input.keybinding !== undefined &&
51775176 (input.keybinding?.trim() || null) !== previousKeybinding &&
51785177 (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
......@@ -5234,8 +5233,6 @@ export default function ChatView(props: ChatViewProps) {
52345233 );
52355234 }
52365235
5237 if (!isElectron) return updateResult;
5238
52395236 const scriptId = input.keybindingCommand
52405237 ? projectScriptIdFromCommand(input.keybindingCommand)
52415238 : null;
apps/web/src/components/settings/useProjectScriptSettings.test.tsx created+147
......@@ -0,0 +1,147 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 ProjectId,
5 type ProjectScript,
6 type ResolvedKeybindingsConfig,
7} from "@t3tools/contracts";
8import { AsyncResult } from "effect/reactivity";
9import { act, create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12import type { NewProjectScriptInput } from "../projectScriptEditor";
13
14const commands = vi.hoisted(() => ({
15 updateSettings: vi.fn(),
16 upsertKeybinding: vi.fn(),
17 removeKeybinding: vi.fn(),
18}));
19
20// A browser client: the shortcut must still reach the environment's keybindings.
21vi.mock("../../env", () => ({ isElectron: false }));
22vi.mock("../../state/session", () => ({ readEnvironmentScope: () => true }));
23vi.mock("../../state/entities", () => ({ useProjects: () => [] }));
24vi.mock("../../state/server", () => ({
25 serverEnvironment: {
26 updateSettings: "updateSettings",
27 upsertKeybinding: "upsertKeybinding",
28 removeKeybinding: "removeKeybinding",
29 },
30}));
31vi.mock("../../state/use-atom-command", () => ({
32 useAtomCommand: (command: keyof typeof commands) => commands[command],
33}));
34vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
35
36import { useProjectScriptSettings } from "./useProjectScriptSettings";
37
38const environmentId = EnvironmentId.make("remote");
39const lint: ProjectScript = {
40 id: "lint",
41 name: "Lint",
42 command: "vp lint",
43 icon: "lint",
44 runOnWorktreeCreate: false,
45};
46const lintBinding: ResolvedKeybindingsConfig = [
47 {
48 command: "script.lint.run",
49 shortcut: {
50 key: "k",
51 metaKey: false,
52 ctrlKey: false,
53 shiftKey: true,
54 altKey: false,
55 modKey: true,
56 },
57 },
58];
59const input = (keybinding: string | null): NewProjectScriptInput => ({
60 name: lint.name,
61 command: lint.command,
62 icon: lint.icon,
63 runOnWorktreeCreate: false,
64 waitForSetup: false,
65 runOnSettle: false,
66 keybinding,
67 previewUrl: null,
68 autoOpenPreview: false,
69});
70
71type ProjectScriptSettings = ReturnType<typeof useProjectScriptSettings>;
72
73function Probe(props: {
74 keybindings: ResolvedKeybindingsConfig;
75 onResult: (settings: ProjectScriptSettings) => void;
76}) {
77 props.onResult(
78 useProjectScriptSettings([
79 {
80 environmentId,
81 settings: DEFAULT_SERVER_SETTINGS,
82 keybindings: props.keybindings,
83 project: { id: ProjectId.make("project"), scripts: [lint] },
84 },
85 ]),
86 );
87 return null;
88}
89
90let renderer: ReactTestRenderer | undefined;
91
92function renderSettings(keybindings: ResolvedKeybindingsConfig) {
93 let settings: ProjectScriptSettings | undefined;
94 act(() => {
95 renderer = create(<Probe keybindings={keybindings} onResult={(next) => (settings = next)} />);
96 });
97 return settings!;
98}
99
100async function submitInAct(submit: ProjectScriptSettings["submit"], keybinding: string | null) {
101 let result: Awaited<ReturnType<typeof submit>> | undefined;
102 await act(async () => {
103 result = await submit(lint.id, input(keybinding));
104 });
105 return result!;
106}
107
108beforeEach(() => {
109 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
110 vi.clearAllMocks();
111 for (const command of Object.values(commands)) {
112 command.mockResolvedValue(AsyncResult.success(undefined));
113 }
114});
115
116afterEach(() => {
117 act(() => renderer?.unmount());
118 renderer = undefined;
119 vi.unstubAllGlobals();
120});
121
122describe("useProjectScriptSettings in a browser client", () => {
123 it("saves a recorded action shortcut", async () => {
124 const { submit } = renderSettings([]);
125
126 const result = await submitInAct(submit, "mod+shift+k");
127
128 expect(result._tag).toBe("Success");
129 expect(commands.upsertKeybinding).toHaveBeenCalledWith({
130 environmentId,
131 input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }),
132 });
133 });
134
135 it("removes a cleared action shortcut", async () => {
136 const { submit } = renderSettings(lintBinding);
137
138 const result = await submitInAct(submit, null);
139
140 expect(result._tag).toBe("Success");
141 expect(commands.removeKeybinding).toHaveBeenCalledWith({
142 environmentId,
143 input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }),
144 });
145 expect(commands.upsertKeybinding).not.toHaveBeenCalled();
146 });
147});
apps/web/src/components/settings/useProjectScriptSettings.ts+1-3
......@@ -19,7 +19,6 @@ import * as Cause from "effect/Cause";
1919import { AsyncResult } from "effect/reactivity";
2020import { useRef, useState } from "react";
2121
22import { isElectron } from "../../env";
2322import {
2423 decodeProjectScriptKeybindingRule,
2524 keybindingValueForCommand,
......@@ -50,7 +49,7 @@ function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {
5049
5150/**
5251 * Edits the action list on every target: the environment default when there is
53 * no project, else that project's override entry. Shortcuts follow on desktop.
52 * no project, else that project's override entry, then saves changed shortcuts.
5453 */
5554export function useProjectScriptSettings(
5655 targets: readonly {
......@@ -129,7 +128,6 @@ export function useProjectScriptSettings(
129128 },
130129 });
131130 if (result._tag === "Failure") return reportScriptFailure(result);
132 if (!isElectron) continue;
133131 const changedIds = scriptId
134132 ? [scriptId]
135133 : current