From f3a088313f7aa5f834c1a8ed41c778aa2fd483b6 Mon Sep 17 00:00:00 2001 From: m-de-graaff Date: Mon, 27 Jul 2026 10:18:20 +0200 Subject: [PATCH 1/3] feat(web): add a reset-all button to keybindings settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no way to get back to the stock keybindings from the GUI. A user carrying customizations from an older version can end up with conflicting bindings and no recourse short of hand-editing keybindings.json — which the settings screen already links to, but which is exactly what someone hitting this problem does not want to do. Adds `server.resetKeybindings`, which rewrites the config with the default rule set, and a reset button beside Add keybinding and Open keybindings.json. The action confirms first, since it discards every customization at once. Project script bindings (`script.*`) are kept. They have no default to restore, so dropping them would delete shortcuts the reset cannot give back. A config that fails to parse is replaced outright rather than erroring — that is the state the reset exists to escape. Closes #4576 Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/keybindings.test.ts | 45 +++++++++++++++++ apps/server/src/keybindings.ts | 42 ++++++++++++++++ apps/server/src/ws.ts | 10 ++++ .../settings/KeybindingsSettings.tsx | 50 +++++++++++++++++++ packages/client-runtime/src/state/server.ts | 6 +++ packages/contracts/src/rpc.ts | 9 ++++ packages/contracts/src/server.ts | 3 ++ 7 files changed, 165 insertions(+) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac84167..c47b49e85e98 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -454,6 +454,51 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("resets customized keybindings to defaults while keeping script bindings", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+r", command: "script.run-tests.run" }, + ]); + + const resolved = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.resetKeybindingRulesToDefaults; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [ + ...Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })), + { key: "mod+r", command: "script.run-tests.run" }, + ]); + assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("resets a malformed keybindings config to defaults", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true }); + yield* fs.writeFileString(keybindingsConfigPath, "{ not an array"); + + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.resetKeybindingRulesToDefaults; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual( + persistedView, + Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })), + ); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe36232..c0bdaa465fb7 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -109,6 +109,10 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +function isScriptKeybindingRule(rule: KeybindingRule): boolean { + return String(rule.command).startsWith("script."); +} + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -284,6 +288,19 @@ export class Keybindings extends Context.Service< readonly removeKeybindingRule: ( input: ServerRemoveKeybindingInput, ) => Effect.Effect; + + /** + * Restore every default keybinding, discarding customizations. + * + * Project script bindings are kept — they have no default to restore, so + * dropping them would delete shortcuts the reset cannot give back. A config + * that fails to parse is replaced outright, since that is the state the + * reset exists to escape. + */ + readonly resetKeybindingRulesToDefaults: Effect.Effect< + ResolvedKeybindingsConfig, + KeybindingsConfigError + >; } >()("t3/keybindings") {} @@ -698,6 +715,31 @@ const make = Effect.gen(function* () { return nextResolved; }), ), + resetKeybindingRulesToDefaults: upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + const loaded = yield* Effect.result(loadWritableCustomKeybindingsConfig()); + const customConfig = loaded._tag === "Success" ? loaded.success : []; + const preservedScripts = customConfig.filter((entry) => isScriptKeybindingRule(entry)); + const nextConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + yield* writeConfigAtomically(cappedConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(cappedConfig), + ); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange({ + keybindings: nextResolved, + issues: [], + }); + return nextResolved; + }), + ), } satisfies Keybindings["Service"]; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76ed..ed39f1656015 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -300,6 +300,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], + [WS_METHODS.serverResetKeybindings, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], @@ -1501,6 +1502,15 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverResetKeybindings]: (_input) => + observeRpcEffect( + WS_METHODS.serverResetKeybindings, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.resetKeybindingRulesToDefaults; + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverGetSettings]: (_input) => observeRpcEffect( WS_METHODS.serverGetSettings, diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 67c33c9b9423..8f793eac9d13 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -6,6 +6,7 @@ import { InfoIcon, MinusIcon, PlusIcon, + RotateCcwIcon, SearchIcon, TriangleAlertIcon, XIcon, @@ -35,6 +36,7 @@ import { import { isElectron } from "../../env"; import { useOpenInPreferredEditor } from "../../editorPreferences"; +import { readLocalApi } from "../../localApi"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; import { @@ -1092,6 +1094,9 @@ export function KeybindingsSettingsPanel() { const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); + const resetKeybindingsMutation = useAtomCommand(serverEnvironment.resetKeybindings, { + reportFailure: false, + }); const openInPreferredEditor = useOpenInPreferredEditor( primaryEnvironment?.environmentId ?? null, availableEditors, @@ -1220,6 +1225,33 @@ export function KeybindingsSettingsPanel() { [saveKeybinding], ); + const [isResetting, setIsResetting] = useState(false); + const resetAllKeybindings = useCallback(() => { + if (!primaryEnvironment) return; + void (async () => { + const confirmed = await readLocalApi()?.dialogs.confirm( + [ + "Reset all keybindings to their defaults?", + "Every customization is discarded. Project script bindings are kept.", + ].join("\n"), + ); + if (!confirmed) return; + setIsResetting(true); + const result = await resetKeybindingsMutation({ + environmentId: primaryEnvironment.environmentId, + input: {}, + }); + setIsResetting(false); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add({ + title: "Unable to reset keybindings", + description: error instanceof Error ? error.message : "The keybindings were not reset.", + type: "error", + }); + })(); + }, [primaryEnvironment, resetKeybindingsMutation]); + const bindingsCount = ( {rows.length + (isAddingBinding ? 1 : 0)}{" "} @@ -1258,6 +1290,24 @@ export function KeybindingsSettingsPanel() { /> Add keybinding + + + + + } + /> + Reset all to defaults + ( scheduler: configScheduler, concurrency: configConcurrency, }), + resetKeybindings: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:reset-keybindings", + tag: WS_METHODS.serverResetKeybindings, + scheduler: configScheduler, + concurrency: configConcurrency, + }), updateSettings: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-settings", tag: WS_METHODS.serverUpdateSettings, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef29..8ec4f36aff7e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -121,6 +121,7 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerResetKeybindingsResult, ServerProviderUpdatedPayload, ServerSelfUpdateError, ServerSelfUpdateInput, @@ -211,6 +212,7 @@ export const WS_METHODS = { serverUpdateServer: "server.updateServer", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverResetKeybindings: "server.resetKeybindings", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -251,6 +253,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); +export const WsServerResetKeybindingsRpc = Rpc.make(WS_METHODS.serverResetKeybindings, { + payload: Schema.Struct({}), + success: ServerResetKeybindingsResult, + error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), +}); + export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { payload: Schema.Struct({}), success: Schema.Struct({}), @@ -706,6 +714,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerResetKeybindingsRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a8394..14afc44c4550 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -451,6 +451,9 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerRemoveKeybindingResult = ServerUpsertKeybindingResult; export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type; +export const ServerResetKeybindingsResult = ServerUpsertKeybindingResult; +export type ServerResetKeybindingsResult = typeof ServerResetKeybindingsResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviders, From 90f7248d45beb58985fa64caa5f74dcab31e4986 Mon Sep 17 00:00:00 2001 From: m-de-graaff Date: Mon, 27 Jul 2026 10:34:28 +0200 Subject: [PATCH 2/3] fix(server): harden keybindings reset against read failures and the max cap Three review findings, all real: - The reset swallowed every load failure, not just parse failures. A transient read error would resolve to "no rules" and the write would then drop the user's script bindings. It now loads through the runtime loader, which returns an empty rule set for a config it cannot parse but still fails on a filesystem error, so an unreadable config aborts the reset instead of overwriting it. - Capping with slice(-MAX) kept the trailing script rules and dropped default rules from the front, so a reset could fail to restore the defaults it exists to restore. Truncate the preserved scripts instead, and log what was dropped. - The web handler captured the primary environment id before awaiting the confirm dialog. Switching environments while the dialog was open would reset the wrong one. It now re-checks the current environment after the dialog resolves. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/keybindings.test.ts | 55 ++++++++++++++++++- apps/server/src/keybindings.ts | 26 ++++++--- .../settings/KeybindingsSettings.tsx | 14 ++++- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index c47b49e85e98..e6937cc54956 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,4 +1,9 @@ -import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; +import { + KeybindingCommand, + KeybindingRule, + KeybindingsConfig, + MAX_KEYBINDINGS_COUNT, +} from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; @@ -499,6 +504,54 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("refuses to reset a keybindings config it cannot read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + // A directory at the config path fails the read the same way a transient + // filesystem error would, without overwriting rules that are still there. + yield* fs.makeDirectory(keybindingsConfigPath, { recursive: true }); + + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.resetKeybindingRulesToDefaults; + }).pipe(toDetailResult); + + assertFailure(result, "failed to read keybindings config"); + assert.isTrue((yield* fs.stat(keybindingsConfigPath)).type === "Directory"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("keeps every default when preserved script bindings exceed the max count", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const scriptBudget = MAX_KEYBINDINGS_COUNT - Keybindings.DEFAULT_KEYBINDINGS.length; + const scriptRules = Array.from( + { length: scriptBudget + 5 }, + (_, index): KeybindingRule => ({ + key: "mod+r", + command: `script.s${index}.run`, + }), + ); + yield* writeKeybindingsConfig(keybindingsConfigPath, scriptRules); + + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.resetKeybindingRulesToDefaults; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedDefaults = persisted + .filter((rule) => !String(rule.command).startsWith("script.")) + .map(({ key, command }) => ({ key, command })); + assert.deepEqual( + persistedDefaults, + Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })), + ); + assert.equal(persisted.length, MAX_KEYBINDINGS_COUNT); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index c0bdaa465fb7..ecf588de51dd 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -295,7 +295,8 @@ export class Keybindings extends Context.Service< * Project script bindings are kept — they have no default to restore, so * dropping them would delete shortcuts the reset cannot give back. A config * that fails to parse is replaced outright, since that is the state the - * reset exists to escape. + * reset exists to escape; a config that cannot be read at all fails + * instead, rather than overwriting rules that are still on disk. */ readonly resetKeybindingRulesToDefaults: Effect.Effect< ResolvedKeybindingsConfig, @@ -717,14 +718,23 @@ const make = Effect.gen(function* () { ), resetKeybindingRulesToDefaults: upsertSemaphore.withPermits(1)( Effect.gen(function* () { - const loaded = yield* Effect.result(loadWritableCustomKeybindingsConfig()); - const customConfig = loaded._tag === "Success" ? loaded.success : []; + // The runtime loader, not the writable one: a config that cannot be + // parsed must resolve to "no rules to preserve" so the reset can still + // replace it, while a filesystem failure still aborts rather than + // overwriting a file we could not read. + const { keybindings: customConfig } = yield* loadRuntimeCustomKeybindingsConfig(); const preservedScripts = customConfig.filter((entry) => isScriptKeybindingRule(entry)); - const nextConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS_COUNT - ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) - : nextConfig; + // Truncate the preserved scripts, never the defaults — a reset that + // dropped default rules to stay under the cap would defeat itself. + const scriptBudget = Math.max(0, MAX_KEYBINDINGS_COUNT - DEFAULT_KEYBINDINGS.length); + const cappedConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts.slice(0, scriptBudget)]; + if (preservedScripts.length > scriptBudget) { + yield* Effect.logWarning("dropping script keybindings to stay under max entries", { + path: keybindingsConfigPath, + maxEntries: MAX_KEYBINDINGS_COUNT, + dropped: preservedScripts.length - scriptBudget, + }); + } yield* writeConfigAtomically(cappedConfig); const nextResolved = mergeWithDefaultKeybindings( compileResolvedKeybindingsConfig(cappedConfig), diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 8f793eac9d13..52e75c823056 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1226,8 +1226,13 @@ export function KeybindingsSettingsPanel() { ); const [isResetting, setIsResetting] = useState(false); + const primaryEnvironmentRef = useRef(primaryEnvironment); + useEffect(() => { + primaryEnvironmentRef.current = primaryEnvironment; + }, [primaryEnvironment]); const resetAllKeybindings = useCallback(() => { - if (!primaryEnvironment) return; + const environmentId = primaryEnvironment?.environmentId; + if (!environmentId) return; void (async () => { const confirmed = await readLocalApi()?.dialogs.confirm( [ @@ -1236,9 +1241,12 @@ export function KeybindingsSettingsPanel() { ].join("\n"), ); if (!confirmed) return; + // The primary environment can change while the dialog is open, and this + // discards every customization — target only what the user was shown. + if (primaryEnvironmentRef.current?.environmentId !== environmentId) return; setIsResetting(true); const result = await resetKeybindingsMutation({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: {}, }); setIsResetting(false); @@ -1250,7 +1258,7 @@ export function KeybindingsSettingsPanel() { type: "error", }); })(); - }, [primaryEnvironment, resetKeybindingsMutation]); + }, [primaryEnvironment?.environmentId, resetKeybindingsMutation]); const bindingsCount = ( From 327f00c1c4e38bfee231a3e05ca7c98293e1c2d5 Mon Sep 17 00:00:00 2001 From: m-de-graaff Date: Mon, 27 Jul 2026 10:55:27 +0200 Subject: [PATCH 3/3] fix: truncate the newest-losing end of preserved scripts, toast the aborted reset Two follow-up review findings: - Script truncation kept the earliest rules, but later rules have higher precedence throughout this file, so the cap could discard the script bindings actually in effect and keep the ones they shadow. Drop from the front instead, and pin the direction in the test. - The environment guard added after the confirm dialog returned silently. A user who confirmed a reset that then did not run had no way to tell. Toast instead. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/keybindings.test.ts | 7 +++++++ apps/server/src/keybindings.ts | 11 +++++++---- .../src/components/settings/KeybindingsSettings.tsx | 9 ++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e6937cc54956..27e52b7cccb7 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -549,6 +549,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })), ); assert.equal(persisted.length, MAX_KEYBINDINGS_COUNT); + // Later rules win, so the surviving scripts must be the trailing ones. + assert.deepEqual( + persisted + .filter((rule) => String(rule.command).startsWith("script.")) + .map((rule) => rule.command), + scriptRules.slice(5).map((rule) => rule.command), + ); }).pipe(Effect.provide(makeKeybindingsLayer())), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index ecf588de51dd..2a8e6beb85cb 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -725,14 +725,17 @@ const make = Effect.gen(function* () { const { keybindings: customConfig } = yield* loadRuntimeCustomKeybindingsConfig(); const preservedScripts = customConfig.filter((entry) => isScriptKeybindingRule(entry)); // Truncate the preserved scripts, never the defaults — a reset that - // dropped default rules to stay under the cap would defeat itself. + // dropped default rules to stay under the cap would defeat itself. Drop + // from the front, because later rules have higher precedence, so the + // trailing scripts are the ones actually in effect. const scriptBudget = Math.max(0, MAX_KEYBINDINGS_COUNT - DEFAULT_KEYBINDINGS.length); - const cappedConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts.slice(0, scriptBudget)]; - if (preservedScripts.length > scriptBudget) { + const droppedScripts = Math.max(0, preservedScripts.length - scriptBudget); + const cappedConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts.slice(droppedScripts)]; + if (droppedScripts > 0) { yield* Effect.logWarning("dropping script keybindings to stay under max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, - dropped: preservedScripts.length - scriptBudget, + dropped: droppedScripts, }); } yield* writeConfigAtomically(cappedConfig); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 52e75c823056..562affa55552 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1243,7 +1243,14 @@ export function KeybindingsSettingsPanel() { if (!confirmed) return; // The primary environment can change while the dialog is open, and this // discards every customization — target only what the user was shown. - if (primaryEnvironmentRef.current?.environmentId !== environmentId) return; + if (primaryEnvironmentRef.current?.environmentId !== environmentId) { + toastManager.add({ + title: "Keybindings were not reset", + description: "The active environment changed while the confirmation was open.", + type: "error", + }); + return; + } setIsResetting(true); const result = await resetKeybindingsMutation({ environmentId,