+
+
+ );
+}
+
+function PluginShortcutRow({
+ entry,
+ plugin,
+ overrides,
+ baselineOverrides,
+ conflictsWith,
+ touchSized,
+ onChange,
+ onReset,
+ onClear,
+}: {
+ entry: PluginShortcutEntry;
+ plugin: PluginRecord;
+ overrides: StoredShortcutOverrides;
+ baselineOverrides: StoredShortcutOverrides;
+ conflictsWith?: string[];
+ touchSized: boolean;
+ onChange: (id: string, shortcut: KeyboardShortcut) => void;
+ onReset: (id: string) => void;
+ onClear: (id: string) => void;
+}) {
+ const keybinding = plugin.ui?.keybindings?.find(({ id }) => id === entry.keybindingId);
+ const current = resolveShortcutEntry(entry, overrides);
+ return (
+
+ );
+}
diff --git a/apps/web/components/settings/plugins/use-plugin-shortcut-draft.ts b/apps/web/components/settings/plugins/use-plugin-shortcut-draft.ts
new file mode 100644
index 0000000000..5accdfc1c4
--- /dev/null
+++ b/apps/web/components/settings/plugins/use-plugin-shortcut-draft.ts
@@ -0,0 +1,176 @@
+"use client";
+
+import {
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type Dispatch,
+ type SetStateAction,
+} from "react";
+import { useAppStore, useAppStoreApi } from "@/components/state-provider";
+import { updateUserSettings } from "@/lib/api";
+import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
+import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
+import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
+import type { UserSettingsState } from "@/lib/state/slices/settings/types";
+import type { UserSettingsResponse } from "@/lib/types/http";
+import { useSettingsSaveContributor } from "../settings-save-provider";
+
+type ShortcutOverride = StoredShortcutOverrides[string];
+
+function modifierMapsEqual(
+ left: ShortcutOverride["modifiers"],
+ right: ShortcutOverride["modifiers"],
+): boolean {
+ const leftEntries = Object.entries(left ?? {});
+ const rightEntries = Object.entries(right ?? {});
+ return (
+ leftEntries.length === rightEntries.length &&
+ leftEntries.every(([modifier, enabled]) => right?.[modifier] === enabled)
+ );
+}
+
+function shortcutOverridesEqual(
+ left: ShortcutOverride | undefined,
+ right: ShortcutOverride | undefined,
+): boolean {
+ if (!left || !right) return left === right;
+ return left.key === right.key && modifierMapsEqual(left.modifiers, right.modifiers);
+}
+
+function overrideMapsEqual(left: StoredShortcutOverrides, right: StoredShortcutOverrides): boolean {
+ const leftIds = Object.keys(left);
+ return (
+ leftIds.length === Object.keys(right).length &&
+ leftIds.every((id) => shortcutOverridesEqual(left[id], right[id]))
+ );
+}
+
+export function rebaseShortcutOverrides(
+ draft: StoredShortcutOverrides,
+ baseline: StoredShortcutOverrides,
+ nextBaseline: StoredShortcutOverrides,
+): StoredShortcutOverrides {
+ const rebased = { ...nextBaseline };
+ const candidateIds = new Set([...Object.keys(baseline), ...Object.keys(draft)]);
+ for (const id of candidateIds) {
+ const draftOverride = draft[id];
+ if (shortcutOverridesEqual(draftOverride, baseline[id])) continue;
+ if (draftOverride) rebased[id] = draftOverride;
+ else delete rebased[id];
+ }
+ return rebased;
+}
+
+function shortcutOverridesRevision(overrides: StoredShortcutOverrides): string {
+ return JSON.stringify(
+ Object.keys(overrides)
+ .sort()
+ .map((id) => {
+ const override = overrides[id];
+ return [id, override.key, Object.entries(override.modifiers ?? {}).sort()];
+ }),
+ );
+}
+
+function selectAuthoritativeSettings(
+ response: UserSettingsResponse,
+ settingsAtSubmit: UserSettingsState,
+ latestSettings: UserSettingsState,
+): { settings: UserSettingsState; responseIsCurrent: boolean } {
+ const responseOrder = compareUserSettingsRevisions(
+ response.settings.revision,
+ latestSettings.revision,
+ );
+ const responseIsCurrent =
+ responseOrder === null ? latestSettings === settingsAtSubmit : responseOrder >= 0;
+ return {
+ settings: responseIsCurrent
+ ? mapUserSettingsResponse(response, latestSettings)
+ : latestSettings,
+ responseIsCurrent,
+ };
+}
+
+export function usePluginShortcutDraft(contributorId: string): {
+ saved: StoredShortcutOverrides;
+ draft: StoredShortcutOverrides;
+ setDraft: Dispatch>;
+ isDirty: boolean;
+} {
+ const userSettings = useAppStore((state) => state.userSettings);
+ const setUserSettings = useAppStore((state) => state.setUserSettings);
+ const storeApi = useAppStoreApi();
+ const initial = { ...userSettings.keyboardShortcuts } as StoredShortcutOverrides;
+ const [saved, setSavedState] = useState(initial);
+ const [draft, setDraftState] = useState(initial);
+ const savedRef = useRef(saved);
+ const draftRef = useRef(draft);
+ const synchronizedSettingsRef = useRef(userSettings);
+ savedRef.current = saved;
+ draftRef.current = draft;
+
+ const replaceSnapshots = useCallback(
+ (nextSaved: StoredShortcutOverrides, nextDraft: StoredShortcutOverrides) => {
+ savedRef.current = nextSaved;
+ draftRef.current = nextDraft;
+ setSavedState(nextSaved);
+ setDraftState(nextDraft);
+ },
+ [],
+ );
+ const setDraft = useCallback>>((update) => {
+ const next =
+ typeof update === "function"
+ ? (update as (current: StoredShortcutOverrides) => StoredShortcutOverrides)(
+ draftRef.current,
+ )
+ : update;
+ draftRef.current = next;
+ setDraftState(next);
+ }, []);
+
+ useLayoutEffect(() => {
+ if (synchronizedSettingsRef.current === userSettings) return;
+ const nextSaved = { ...userSettings.keyboardShortcuts } as StoredShortcutOverrides;
+ const nextDraft = rebaseShortcutOverrides(draftRef.current, savedRef.current, nextSaved);
+ synchronizedSettingsRef.current = userSettings;
+ replaceSnapshots(nextSaved, nextDraft);
+ }, [replaceSnapshots, userSettings]);
+
+ const revision = shortcutOverridesRevision(draft);
+ const synchronized = synchronizedSettingsRef.current === userSettings;
+ const isDirty = userSettings.loaded && synchronized && !overrideMapsEqual(draft, saved);
+
+ useSettingsSaveContributor({
+ id: contributorId,
+ revision,
+ isDirty,
+ canSave: userSettings.loaded && synchronized,
+ save: async (submittedRevision) => {
+ const submittedDraft = draft;
+ const settingsAtSubmit = storeApi.getState().userSettings;
+ const submitted = rebaseShortcutOverrides(
+ submittedDraft,
+ saved,
+ settingsAtSubmit.keyboardShortcuts as StoredShortcutOverrides,
+ );
+ const response = await updateUserSettings({ keyboard_shortcuts: submitted });
+ const latestSettings = storeApi.getState().userSettings;
+ const authoritative = selectAuthoritativeSettings(response, settingsAtSubmit, latestSettings);
+ const nextSaved = {
+ ...authoritative.settings.keyboardShortcuts,
+ } as StoredShortcutOverrides;
+ const draftChanged = shortcutOverridesRevision(draftRef.current) !== submittedRevision;
+ const nextDraft = draftChanged
+ ? rebaseShortcutOverrides(draftRef.current, submittedDraft, nextSaved)
+ : nextSaved;
+ replaceSnapshots(nextSaved, nextDraft);
+ if (authoritative.responseIsCurrent) setUserSettings(authoritative.settings);
+ },
+ discard: () => setDraft(savedRef.current),
+ });
+
+ return { saved, draft, setDraft, isDirty };
+}
diff --git a/apps/web/e2e/tests/auth/plugin-settings-member.spec.ts b/apps/web/e2e/tests/auth/plugin-settings-member.spec.ts
index ddda815943..9f5b5b8665 100644
--- a/apps/web/e2e/tests/auth/plugin-settings-member.spec.ts
+++ b/apps/web/e2e/tests/auth/plugin-settings-member.spec.ts
@@ -2,10 +2,12 @@ import { expect } from "@playwright/test";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { login, setupAdmin } from "../../helpers/auth";
+import { waitForHttp } from "../../helpers/causal-waits";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
const ADMIN = { email: "plugin-admin@demo.dev", password: "adminpass123", displayName: "Ada" };
const MEMBER = { email: "plugin-member@demo.dev", password: "memberpass123", displayName: "Sam" };
+const PLUGIN_SHORTCUT_ID = `plugin:${PLUGIN_ID}:open-demo`;
test.describe.serial("member plugin settings", () => {
test.beforeAll(async ({ backend }) => {
@@ -42,35 +44,79 @@ test.describe.serial("member plugin settings", () => {
const memberContext = await browser.newContext({ baseURL: backend.frontendUrl });
await login(memberContext, backend.baseUrl, MEMBER);
const page = await memberContext.newPage();
- await page.goto("/settings/plugins");
-
- const settingsPanel = page.locator('[data-testid="settings-scroll-container"]:visible');
- await expect(settingsPanel).toBeVisible();
-
- const row = settingsPanel.getByTestId(`plugin-row-${PLUGIN_ID}`);
- await expect(row).toBeVisible({ timeout: 15_000 });
- await expect(row.getByTestId(`plugin-row-link-${PLUGIN_ID}`)).toBeVisible();
- await expect(settingsPanel.getByTestId("install-plugin-trigger")).toHaveCount(0);
- await expect(settingsPanel.getByTestId("plugins-sync-button")).toHaveCount(0);
- await expect(settingsPanel.getByTestId("plugins-check-updates-button")).toHaveCount(0);
- await expect(settingsPanel.getByTestId("plugins-auto-update-default")).toHaveCount(0);
- await expect(row.getByRole("button", { name: /Enable|Disable|Uninstall|Update/ })).toHaveCount(
- 0,
+ const baselineSettingsResponse = await memberContext.request.get(
+ `${backend.baseUrl}/api/v1/user/settings`,
);
- await expect(row.getByTestId(`plugin-auto-update-${PLUGIN_ID}`)).toHaveCount(0);
+ expect(baselineSettingsResponse.status(), await baselineSettingsResponse.text()).toBe(200);
+ const baselineSettings = (await baselineSettingsResponse.json()).settings as {
+ keyboard_shortcuts?: Record;
+ };
- await row.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click();
- await expect(settingsPanel.getByTestId("plugin-manifest-card")).toBeVisible();
- await expect(settingsPanel.getByTestId("plugin-settings-card")).toHaveCount(0);
- await expect(settingsPanel.getByRole("button", { name: "Uninstall" })).toHaveCount(0);
+ try {
+ await page.goto("/settings/plugins");
- await page.goto("/settings/plugins");
- await settingsPanel.getByTestId("plugins-tab-browse").click();
- await expect(settingsPanel.getByTestId("marketplace-search")).toBeVisible();
- await expect(settingsPanel.getByTestId("marketplace-manage-sources")).toHaveCount(0);
- await expect(settingsPanel.getByRole("button", { name: "Refresh" })).toHaveCount(0);
+ const settingsPanel = page.locator('[data-testid="settings-scroll-container"]:visible');
+ await expect(settingsPanel).toBeVisible();
- await memberContext.close();
- await adminContext.close();
+ const row = settingsPanel.getByTestId(`plugin-row-${PLUGIN_ID}`);
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await expect(row.getByTestId(`plugin-row-link-${PLUGIN_ID}`)).toBeVisible();
+ await expect(settingsPanel.getByTestId("install-plugin-trigger")).toHaveCount(0);
+ await expect(settingsPanel.getByTestId("plugins-sync-button")).toHaveCount(0);
+ await expect(settingsPanel.getByTestId("plugins-check-updates-button")).toHaveCount(0);
+ await expect(settingsPanel.getByTestId("plugins-auto-update-default")).toHaveCount(0);
+ await expect(
+ row.getByRole("button", { name: /Enable|Disable|Uninstall|Update/ }),
+ ).toHaveCount(0);
+ await expect(row.getByTestId(`plugin-auto-update-${PLUGIN_ID}`)).toHaveCount(0);
+
+ // Plugin-owned shortcut rows are not part of the global Kandev shortcut page.
+ await page.goto("/settings/preferences/keyboard-shortcuts");
+ await expect(page.getByTestId("shortcut-recorder-SEARCH")).toBeVisible();
+ await expect(page.getByTestId(`shortcut-recorder-${PLUGIN_SHORTCUT_ID}`)).toHaveCount(0);
+
+ await page.goto("/settings/plugins");
+ await row.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click();
+ await expect(settingsPanel.getByTestId("plugin-manifest-card")).toBeVisible();
+ await expect(settingsPanel.getByTestId("plugin-settings-card")).toHaveCount(0);
+ await expect(settingsPanel.getByRole("button", { name: "Uninstall" })).toHaveCount(0);
+
+ // Members can edit personal plugin shortcuts even though operator controls stay hidden.
+ const shortcutCard = settingsPanel.getByTestId("plugin-shortcuts-card");
+ await expect(shortcutCard).toBeVisible();
+ const shortcutRecorder = shortcutCard.getByTestId(`shortcut-recorder-${PLUGIN_SHORTCUT_ID}`);
+ await expect(shortcutRecorder).toBeVisible();
+ await shortcutRecorder.click();
+ await page.keyboard.press("Control+Alt+p");
+ await expect(shortcutRecorder).toHaveText("Ctrl+Alt+P");
+ const settingsSaved = waitForHttp(page, "PATCH", /^\/api\/v1\/user\/settings$/, {
+ predicate: (response) => response.ok(),
+ });
+ await page
+ .getByTestId("settings-floating-save")
+ .getByRole("button", {
+ name: "Save changes",
+ })
+ .click();
+ await settingsSaved;
+
+ await page.reload();
+ await expect(settingsPanel.getByTestId(`shortcut-recorder-${PLUGIN_SHORTCUT_ID}`)).toHaveText(
+ "Ctrl+Alt+P",
+ );
+
+ await page.goto("/settings/plugins");
+ await settingsPanel.getByTestId("plugins-tab-browse").click();
+ await expect(settingsPanel.getByTestId("marketplace-search")).toBeVisible();
+ await expect(settingsPanel.getByTestId("marketplace-manage-sources")).toHaveCount(0);
+ await expect(settingsPanel.getByRole("button", { name: "Refresh" })).toHaveCount(0);
+ } finally {
+ const restore = await memberContext.request.patch(`${backend.baseUrl}/api/v1/user/settings`, {
+ data: { keyboard_shortcuts: baselineSettings.keyboard_shortcuts ?? {} },
+ });
+ expect(restore.status(), await restore.text()).toBe(200);
+ await memberContext.close();
+ await adminContext.close();
+ }
});
});
diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts
index 464df75954..de32139c26 100644
--- a/apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts
+++ b/apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts
@@ -13,75 +13,131 @@
* part of the setup and every assertion here is about the same rendered row.
*/
import { expect, test } from "../../fixtures/test-base";
+import { waitForHttp } from "../../helpers/causal-waits";
import { PLUGIN_ID, installFixturePlugin } from "../../helpers/plugin-fixture";
-test("mobile plugin row: whole card opens settings, controls still act", async ({ testPage }) => {
+test("mobile plugin row: whole card opens settings, controls still act", async ({
+ testPage,
+ apiClient,
+}) => {
test.setTimeout(180_000);
- await installFixturePlugin(testPage);
- const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
- await expect(pluginRow).toBeVisible({ timeout: 30_000 });
+ const baselineSettings = await apiClient.getUserSettings();
+ const baselineShortcuts = baselineSettings.settings.keyboard_shortcuts ?? {};
- // The fixture declares a required api_token nobody has filled in, so the row
- // advertises the page it wants opened. It has to survive the phone's badge
- // wrapping, not just fit on a desktop title row.
- await expect(pluginRow.getByTestId(`plugin-setup-required-${PLUGIN_ID}`)).toBeVisible({
- timeout: 15_000,
- });
+ try {
+ await installFixturePlugin(testPage);
+ const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
+ await expect(pluginRow).toBeVisible({ timeout: 30_000 });
- // The overlay link is the whole affordance on touch, where nothing hovers,
- // so it has to actually cover the card. It is inset by the row's 1px border,
- // hence the 2px slack; a `space-y-*` margin landing on it once cost the
- // bottom strip of the card.
- const linkBox = await pluginRow.getByTestId(`plugin-row-link-${PLUGIN_ID}`).boundingBox();
- const rowBox = await pluginRow.boundingBox();
- if (!linkBox || !rowBox) throw new Error("plugin row or its overlay link has no layout box");
- expect(linkBox.width).toBeGreaterThanOrEqual(rowBox.width - 2);
- expect(linkBox.height).toBeGreaterThanOrEqual(rowBox.height - 2);
+ // The fixture declares a required api_token nobody has filled in, so the row
+ // advertises the page it wants opened. It has to survive the phone's badge
+ // wrapping, not just fit on a desktop title row.
+ await expect(pluginRow.getByTestId(`plugin-setup-required-${PLUGIN_ID}`)).toBeVisible({
+ timeout: 15_000,
+ });
- // Uninstall keeps its confirmation inside the row on a coarse pointer.
- // Cancel first so the same fixture can cover the existing disable action
- // and the detail-surface confirmation below.
- const rowUninstall = pluginRow.getByRole("button", { name: "Uninstall" });
- expect((await rowUninstall.boundingBox())?.height).toBeGreaterThanOrEqual(44);
- await rowUninstall.tap();
- const rowConfirmation = pluginRow.getByTestId("plugin-uninstall-inline-confirmation");
- await expect(rowConfirmation).toBeVisible();
- await expect(testPage.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
- expect(
- (await rowConfirmation.getByTestId("plugin-uninstall-confirm").boundingBox())?.height,
- ).toBeGreaterThanOrEqual(44);
- await rowConfirmation.getByRole("button", { name: "Cancel" }).tap();
- await expect(rowConfirmation).toHaveCount(0);
+ // The overlay link is the whole affordance on touch, where nothing hovers,
+ // so it has to actually cover the card. It is inset by the row's 1px border,
+ // hence the 2px slack; a `space-y-*` margin landing on it once cost the
+ // bottom strip of the card.
+ const linkBox = await pluginRow.getByTestId(`plugin-row-link-${PLUGIN_ID}`).boundingBox();
+ const rowBox = await pluginRow.boundingBox();
+ if (!linkBox || !rowBox) throw new Error("plugin row or its overlay link has no layout box");
+ expect(linkBox.width).toBeGreaterThanOrEqual(rowBox.width - 2);
+ expect(linkBox.height).toBeGreaterThanOrEqual(rowBox.height - 2);
- // Every control sits above the overlay. If one slipped below it the tap would
- // navigate instead of acting, and on a phone there is no hover state to
- // reveal which is which.
- const disable = pluginRow.getByRole("button", { name: "Disable" });
- expect((await disable.boundingBox())?.height).toBeGreaterThanOrEqual(44);
- await disable.tap();
- await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 15_000 });
- await expect(testPage).toHaveURL(/\/settings\/plugins$/);
+ // Uninstall keeps its confirmation inside the row on a coarse pointer.
+ // Cancel first so the same fixture can cover the existing disable action
+ // and the detail-surface confirmation below.
+ const rowUninstall = pluginRow.getByRole("button", { name: "Uninstall" });
+ expect((await rowUninstall.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await rowUninstall.tap();
+ const rowConfirmation = pluginRow.getByTestId("plugin-uninstall-inline-confirmation");
+ await expect(rowConfirmation).toBeVisible();
+ await expect(testPage.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
+ expect(
+ (await rowConfirmation.getByTestId("plugin-uninstall-confirm").boundingBox())?.height,
+ ).toBeGreaterThanOrEqual(44);
+ await rowConfirmation.getByRole("button", { name: "Cancel" }).tap();
+ await expect(rowConfirmation).toHaveCount(0);
- // Tapping the card body rather than the name is the whole point of the
- // change; keep clear of the row's own controls.
- await pluginRow.tap({ position: { x: 12, y: 12 } });
- await expect(testPage).toHaveURL(new RegExp(`/settings/plugins/${PLUGIN_ID}$`));
- const detail = testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`);
- await expect(detail).toBeVisible();
+ // Every control sits above the overlay. If one slipped below it the tap would
+ // navigate instead of acting, and on a phone there is no hover state to
+ // reveal which is which.
+ const disable = pluginRow.getByRole("button", { name: "Disable" });
+ expect((await disable.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await disable.tap();
+ await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 15_000 });
+ await expect(testPage).toHaveURL(/\/settings\/plugins$/);
- // The detail danger zone uses the same phone-native inline confirmation and
- // keeps both actions at the 44px touch target minimum.
- const detailUninstall = detail.getByRole("button", { name: "Uninstall" });
- expect((await detailUninstall.boundingBox())?.height).toBeGreaterThanOrEqual(44);
- await detailUninstall.tap();
- const detailConfirmation = testPage.getByTestId("plugin-uninstall-inline-confirmation");
- await expect(detailConfirmation).toBeVisible();
- await expect(testPage.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
- expect(
- (await detailConfirmation.getByTestId("plugin-uninstall-confirm").boundingBox())?.height,
- ).toBeGreaterThanOrEqual(44);
- await detailConfirmation.getByTestId("plugin-uninstall-confirm").tap();
- await expect(testPage).toHaveURL(/\/settings\/plugins$/);
- await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toHaveCount(0);
+ // Tapping the card body rather than the name is the whole point of the
+ // change; keep clear of the row's own controls.
+ await pluginRow.tap({ position: { x: 12, y: 12 } });
+ await expect(testPage).toHaveURL(new RegExp(`/settings/plugins/${PLUGIN_ID}$`));
+ const detail = testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`);
+ await expect(detail).toBeVisible();
+
+ // Disabled plugins retain editable declarations, but their handlers only run when active.
+ const shortcutCard = detail.getByTestId("plugin-shortcuts-card");
+ await expect(shortcutCard).toBeVisible();
+ const shortcutRecorder = shortcutCard.getByTestId(
+ `shortcut-recorder-plugin:${PLUGIN_ID}:open-demo`,
+ );
+ const recorderBox = await shortcutRecorder.boundingBox();
+ expect(recorderBox?.height).toBeGreaterThanOrEqual(44);
+ const settingsPanel = testPage.locator('[data-testid="settings-scroll-container"]:visible');
+ const scrollState = await settingsPanel.evaluate((element) => ({
+ overflowY: getComputedStyle(element).overflowY,
+ scrollHeight: element.scrollHeight,
+ clientHeight: element.clientHeight,
+ }));
+ expect(scrollState.overflowY).toMatch(/auto|scroll/);
+ expect(scrollState.scrollHeight).toBeGreaterThanOrEqual(scrollState.clientHeight);
+ expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
+ await testPage.evaluate(() => document.documentElement.clientWidth),
+ );
+
+ await shortcutRecorder.tap();
+ await testPage.keyboard.press("Control+Alt+p");
+ await expect(shortcutRecorder).toHaveText("Ctrl+Alt+P");
+ const reset = shortcutCard.getByRole("button", { name: "Reset to default" });
+ expect((await reset.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ const settingsSaved = waitForHttp(testPage, "PATCH", /^\/api\/v1\/user\/settings$/, {
+ predicate: (response) => response.ok(),
+ });
+ await testPage
+ .getByTestId("settings-floating-save")
+ .getByRole("button", {
+ name: "Save changes",
+ })
+ .tap();
+ await settingsSaved;
+ await testPage.reload();
+ await expect(
+ testPage
+ .getByTestId("plugin-shortcuts-card")
+ .getByTestId(`shortcut-recorder-plugin:${PLUGIN_ID}:open-demo`),
+ ).toHaveText("Ctrl+Alt+P");
+ expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
+ await testPage.evaluate(() => document.documentElement.clientWidth),
+ );
+
+ // The detail danger zone uses the same phone-native inline confirmation and
+ // keeps both actions at the 44px touch target minimum.
+ const detailUninstall = detail.getByRole("button", { name: "Uninstall" });
+ expect((await detailUninstall.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await detailUninstall.tap();
+ const detailConfirmation = testPage.getByTestId("plugin-uninstall-inline-confirmation");
+ await expect(detailConfirmation).toBeVisible();
+ await expect(testPage.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
+ expect(
+ (await detailConfirmation.getByTestId("plugin-uninstall-confirm").boundingBox())?.height,
+ ).toBeGreaterThanOrEqual(44);
+ await detailConfirmation.getByTestId("plugin-uninstall-confirm").tap();
+ await expect(testPage).toHaveURL(/\/settings\/plugins$/);
+ await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toHaveCount(0);
+ } finally {
+ await apiClient.saveUserSettings({ keyboard_shortcuts: baselineShortcuts });
+ }
});
diff --git a/apps/web/lib/keyboard/plugin-shortcuts.ts b/apps/web/lib/keyboard/plugin-shortcuts.ts
index bf6a16b51a..5f6264527d 100644
--- a/apps/web/lib/keyboard/plugin-shortcuts.ts
+++ b/apps/web/lib/keyboard/plugin-shortcuts.ts
@@ -1,9 +1,9 @@
/**
* Dynamic layer over `CONFIGURABLE_SHORTCUTS`: builds the same
* label/default shape for plugin-declared keybindings (`ui.keybindings` on a
- * `PluginRecord`), so Settings > Keyboard Shortcuts can render and
- * user-override plugin shortcuts alongside the static core list without
- * widening `ConfigurableShortcutId` to an open string type.
+ * `PluginRecord`), so the plugin detail shortcut editor can render
+ * user-overrides without widening `ConfigurableShortcutId` to an open string
+ * type.
*
* Namespacing: a plugin keybinding's effective override id is
* `plugin:{pluginId}:{keybindingId}` — this is what gets stored under
@@ -59,7 +59,7 @@ export function coreShortcutEntries(
}
/**
- * Builds the plugin-sourced configurable shortcut entries for every active
+ * Builds the plugin-sourced configurable shortcut entries for every installed
* plugin's declared `ui.keybindings`. A combo that fails to parse (should
* never happen — the backend validates manifests at registration) is
* skipped with a console warning rather than throwing.
@@ -90,8 +90,8 @@ function buildPluginEntries(plugins: PluginRecord[]): ShortcutEntry[] {
/**
* The full list of configurable shortcuts — core (static) entries followed
- * by plugin (dynamic) entries — for rendering in Settings > Keyboard
- * Shortcuts. Core behavior/order is unchanged from `CONFIGURABLE_SHORTCUTS`.
+ * by plugin (dynamic) entries — for conflict calculation and plugin detail
+ * editors. Core behavior/order is unchanged from `CONFIGURABLE_SHORTCUTS`.
*/
export function buildConfigurableShortcutEntries(
plugins: PluginRecord[],
diff --git a/apps/web/src/locales/en/plugins.json b/apps/web/src/locales/en/plugins.json
index 80e37d1014..96e2b4b3a8 100644
--- a/apps/web/src/locales/en/plugins.json
+++ b/apps/web/src/locales/en/plugins.json
@@ -80,6 +80,8 @@
"resetAutoUpdateFor": "Reset auto-update for {{name}} to the default",
"savingRestartsPlugin": "Saving restarts the plugin so the new settings take effect.",
"searchPlugins": "Search plugins…",
+ "shortcuts": "Keyboard shortcuts",
+ "shortcutsDescription": "These shortcuts are personal to your account and run only while this plugin is active.",
"selectAUtilityAgent": "Select a utility agent...",
"selectPlaceholder": "Select...",
"selectedUtilityAgentUnavailable": "Selected utility agent unavailable",
diff --git a/apps/web/src/locales/pseudo/plugins.json b/apps/web/src/locales/pseudo/plugins.json
index 769fe32554..ad9fac566c 100644
--- a/apps/web/src/locales/pseudo/plugins.json
+++ b/apps/web/src/locales/pseudo/plugins.json
@@ -80,6 +80,8 @@
"resetAutoUpdateFor": "Ŕēśēţ àũţō-ũƥďàţē ƒōŕ {{name}} ţō ţĥē ďēƒàũĺţ",
"savingRestartsPlugin": "Śàvĩńĝ ŕēśţàŕţś ţĥē ƥĺũĝĩń śō ţĥē ńēŵ śēţţĩńĝś ţàķē ēƒƒēćţ.",
"searchPlugins": "Śēàŕćĥ ƥĺũĝĩńś…",
+ "shortcuts": "Ķēŷƀōàŕď śĥōŕţćũţś",
+ "shortcutsDescription": "Ţĥēśē śĥōŕţćũţś àŕē ƥēŕśōńàĺ ţō ŷōũŕ àććōũńţ àńď ŕũń ōńĺŷ ŵĥĩĺē ţĥĩś ƥĺũĝĩń ĩś àćţĩvē.",
"selectAUtilityAgent": "Śēĺēćţ à ũţĩĺĩţŷ àĝēńţ...",
"selectPlaceholder": "Śēĺēćţ...",
"selectedUtilityAgentUnavailable": "Śēĺēćţēď ũţĩĺĩţŷ àĝēńţ ũńàvàĩĺàƀĺē",
diff --git a/apps/web/src/locales/pt-pt/plugins.json b/apps/web/src/locales/pt-pt/plugins.json
index d5c34beff9..68fc152322 100644
--- a/apps/web/src/locales/pt-pt/plugins.json
+++ b/apps/web/src/locales/pt-pt/plugins.json
@@ -79,6 +79,8 @@
"resetAutoUpdateFor": "Repor a atualização automática de {{name}} para a pré-definição",
"savingRestartsPlugin": "Ao guardar, o plugin é reiniciado para que as novas definições entrem em vigor.",
"searchPlugins": "Pesquisar plugins…",
+ "shortcuts": "Atalhos de teclado",
+ "shortcutsDescription": "Estes atalhos são pessoais da sua conta e só funcionam enquanto este plugin estiver ativo.",
"selectAUtilityAgent": "Selecione um agente utilitário...",
"selectPlaceholder": "Selecionar...",
"selectedUtilityAgentUnavailable": "Agente utilitário selecionado indisponível",
diff --git a/apps/web/src/locales/zh-cn/plugins.json b/apps/web/src/locales/zh-cn/plugins.json
index d0f068d27a..86843c4aea 100644
--- a/apps/web/src/locales/zh-cn/plugins.json
+++ b/apps/web/src/locales/zh-cn/plugins.json
@@ -79,6 +79,8 @@
"resetAutoUpdateFor": "将 {{name}} 的自动更新重置为默认",
"savingRestartsPlugin": "保存后将重启插件以使新设置生效。",
"searchPlugins": "搜索插件…",
+ "shortcuts": "键盘快捷键",
+ "shortcutsDescription": "这些快捷键属于您的个人账户,仅在此插件处于活跃状态时运行。",
"selectAUtilityAgent": "选择辅助智能体…",
"selectPlaceholder": "选择…",
"selectedUtilityAgentUnavailable": "所选辅助智能体不可用",
diff --git a/apps/web/src/locales/zh-hk/plugins.json b/apps/web/src/locales/zh-hk/plugins.json
index f77b15c230..4fb8157ed7 100644
--- a/apps/web/src/locales/zh-hk/plugins.json
+++ b/apps/web/src/locales/zh-hk/plugins.json
@@ -79,6 +79,8 @@
"resetAutoUpdateFor": "將 {{name}} 的自動更新重置為預設",
"savingRestartsPlugin": "儲存後將重啓外掛程式以使新設定生效。",
"searchPlugins": "搜尋外掛程式…",
+ "shortcuts": "鍵盤快捷鍵",
+ "shortcutsDescription": "這些快捷鍵屬於您的個人帳戶,僅在此外掛程式處於活躍狀態時執行。",
"selectAUtilityAgent": "選擇輔助代理程式…",
"selectPlaceholder": "選擇…",
"selectedUtilityAgentUnavailable": "所選輔助代理程式不可用",
diff --git a/apps/web/src/locales/zh-tw/plugins.json b/apps/web/src/locales/zh-tw/plugins.json
index f670393d76..5c4b669ba1 100644
--- a/apps/web/src/locales/zh-tw/plugins.json
+++ b/apps/web/src/locales/zh-tw/plugins.json
@@ -79,6 +79,8 @@
"resetAutoUpdateFor": "將 {{name}} 的自動更新重置為預設",
"savingRestartsPlugin": "儲存後將重啟外掛以使新設定生效。",
"searchPlugins": "搜尋外掛…",
+ "shortcuts": "鍵盤快捷鍵",
+ "shortcutsDescription": "這些快捷鍵屬於您的個人帳戶,僅在此外掛處於活躍狀態時執行。",
"selectAUtilityAgent": "選擇輔助代理程式…",
"selectPlaceholder": "選擇…",
"selectedUtilityAgentUnavailable": "所選輔助代理程式不可用",
diff --git a/apps/web/src/settings-routes.plugin.test.ts b/apps/web/src/settings-routes.plugin.test.ts
index 547d9430f5..2f87881ae8 100644
--- a/apps/web/src/settings-routes.plugin.test.ts
+++ b/apps/web/src/settings-routes.plugin.test.ts
@@ -8,6 +8,19 @@ vi.mock("@/components/settings/workspaces/workspace-settings-shell", () => ({
WorkspaceSettingsShell: ({ children }: { children: ReactNode }) => children,
}));
+vi.mock("@/hooks/domains/plugins/use-plugins", () => ({
+ usePlugins: () => ({
+ items: [{ id: "plugin-a" }],
+ loaded: true,
+ loading: false,
+ error: null,
+ }),
+}));
+
+vi.mock("@/components/settings/plugins/plugin-shortcuts-card", () => ({
+ PluginShortcutsCard: () => createElement("div", null, "Host-owned plugin shortcuts"),
+}));
+
const PLUGIN_ID = "plugin-a";
const PLUGIN_SETTINGS_PATH = "/settings/plugins/plugin-a/config";
const PLUGIN_INTEGRATION_ID = "source-control";
@@ -45,6 +58,20 @@ describe("renderSettingsRoute — plugin fallthrough", () => {
cleanup();
});
+ it("keeps host-owned shortcuts on an exact plugin detail route replacement", () => {
+ function PluginSettingsPage() {
+ return createElement("div", null, "Plugin-owned detail content");
+ }
+ const detailPath = `/settings/plugins/${PLUGIN_ID}`;
+ pluginRegistry.forPlugin(PLUGIN_ID).registerSettingsRoute(detailPath, PluginSettingsPage);
+
+ render(renderSettingsRoute(detailPath) as ReactElement);
+
+ expect(screen.getByText("Plugin-owned detail content")).not.toBeNull();
+ expect(screen.getByText("Host-owned plugin shortcuts")).not.toBeNull();
+ cleanup();
+ });
+
it("renders a fallback instead of throwing when the plugin settings route component throws", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
function ThrowingSettingsPage(): never {
diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx
index 6f0705ca86..37e22d4f59 100644
--- a/apps/web/src/settings-routes.tsx
+++ b/apps/web/src/settings-routes.tsx
@@ -35,6 +35,7 @@ import { LayoutSettings } from "@/components/settings/layouts/layout-settings";
import { PromptsSettings } from "@/components/settings/prompts-settings";
import { SecretsSettings } from "@/components/settings/secrets-settings";
import { SettingsLayoutClient } from "@/components/settings/settings-layout-client";
+import { PluginShortcutsCard } from "@/components/settings/plugins/plugin-shortcuts-card";
import { TaskBehaviorSettings } from "@/components/settings/task-behavior-settings";
import { TerminalEditorsSettings } from "@/components/settings/terminal-editors-settings";
import { AboutSettings } from "@/components/settings/system/about-settings";
@@ -76,6 +77,7 @@ import {
PluginRouteFallback,
} from "@/components/plugins/plugin-error-boundary";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
+import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
import {
fetchUserSettings,
listAgentDiscovery,
@@ -306,10 +308,14 @@ function renderDynamicSettingsRoute(pathname: string) {
const pluginId = matchSingle(pathname, /^\/settings\/plugins\/([^/]+)$/);
if (pluginId) {
- // A plugin-authored settings route registered at exactly this path
- // (registry.registerSettingsRoute) wins over the first-party detail
- // page, so a plugin can fully replace its own settings surface.
- return renderPluginSettingsRoute(pathname) ?? ;
+ const pluginRoute = renderPluginSettingsRoute(pathname);
+ // A plugin may replace its detail content, but host-owned personal
+ // shortcuts remain reachable beside that contribution.
+ return pluginRoute ? (
+ {pluginRoute}
+ ) : (
+
+ );
}
const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/);
@@ -330,6 +336,23 @@ function renderDynamicSettingsRoute(pathname: string) {
return null;
}
+function PluginRootSettingsRoute({
+ pluginId,
+ children,
+}: {
+ pluginId: string;
+ children: ReactNode;
+}) {
+ const { items } = usePlugins();
+ const plugin = items.find((candidate) => candidate.id === pluginId);
+ return (
+
+ {children}
+ {plugin && }
+
+ );
+}
+
function renderExecutorSettingsRoute(pathname: string): ReactNode {
const executorProfile = matchDouble(
pathname,
diff --git a/docs/plans/plugin-shortcut-settings/plan.md b/docs/plans/plugin-shortcut-settings/plan.md
new file mode 100644
index 0000000000..f92a49b0a8
--- /dev/null
+++ b/docs/plans/plugin-shortcut-settings/plan.md
@@ -0,0 +1,174 @@
+---
+created: 2026-09-04
+status: complete
+requirements:
+ - REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001
+system_design:
+ - ../../specs/plugins/system-design/plugin-shortcut-settings.md
+legacy_specs: []
+---
+
+# Implementation Plan: Plugin Shortcut Settings
+
+## Overview
+
+Move dynamic plugin shortcut editors out of Preferences > Keyboard Shortcuts
+and into each plugin's detail page while preserving the existing per-user
+override map, shared Save behavior, full conflict comparison, and dispatch
+rules. Implement the vertical frontend behavior and its desktop/member/mobile
+evidence first, then reconcile the public plugin references with the shipped
+location.
+
+The plugin system owns this plan because manifest-declared keybindings and
+their namespaced overrides are plugin contracts. The UI system supplies the
+existing save coordinator and responsive settings shell without taking
+ownership of the plugin behavior.
+
+## Scope
+
+### In scope
+
+- Render Kandev-owned shortcuts only on the global keyboard-shortcuts page.
+- Render one selected plugin's declared shortcuts on that plugin's detail
+ route for members and administrators.
+- Preserve namespaced portable overrides, draft/reset/clear semantics,
+ conflict warnings, core precedence, and inactive-plugin behavior.
+- Keep the phone flow within the existing direct plugin detail route, with
+ touch-usable controls and no page-level horizontal overflow.
+- Update user-facing plugin docs, the manifest reference, and the maintained
+ frontend plugin API contract to name the new configuration location and
+ permission split.
+
+### Out of scope
+
+- Backend, manifest-schema, plugin SDK, registry, or shortcut-dispatch changes.
+- A new Settings route, drawer, plugin shortcut search index, or plugin-list
+ shortcut badge/count.
+- Changing access to plugin-authored settings, plugin config, or lifecycle
+ actions.
+- Cleaning up stored shortcut overrides during uninstall.
+- Updating the core keyboard-shortcuts documentation screenshot, which remains
+ accurate as a core-only settings view.
+
+## Technical approach
+
+### Core and plugin shortcut presentation
+
+Keep `KeyboardShortcutsSettings` responsible for the chat-submit preference and
+the Kandev shortcut draft. Continue loading plugin declarations there only so
+core rows can report cross-source conflicts. Refine
+`KeyboardShortcutsCard` so its rendered entries are always
+`CONFIGURABLE_SHORTCUTS`, while its conflict input can still include every
+plugin entry.
+
+Add a plugin-focused shortcut settings component under
+`apps/web/components/settings/plugins/`. It receives the selected plugin and
+complete installed plugin list, filters visible entries to the selected owner,
+and reuses the current recorder and conflict-label logic. Place it outside
+`PluginDetail`'s `canManage` block, after operator configuration and before the
+manifest card. Do not render it for a plugin without parseable keybindings.
+When an active bundle replaces the exact plugin detail route, wrap its content
+and append the same host-owned card so the supported route and keybinding
+capabilities remain composable.
+
+### Persistence and permissions
+
+Register a route-local `plugin-shortcuts:{pluginId}` save contributor. Its
+draft contains the existing complete `StoredShortcutOverrides` map and saves
+through `updateUserSettings({ keyboard_shortcuts: ... })`; it never calls the
+plugin configuration endpoint. Synchronize the baseline after initial
+hydration and later portable-settings updates, rebase local changes onto the
+newest complete map at save time, reject stale response authority, and preserve
+edits made while the request is pending. Preserve the existing namespaced keys
+so no migration or dispatcher change is needed. When an administrator changes
+both plugin config and a personal shortcut, the shared coordinator owns partial
+success, retry, discard, and navigation protection across the two contributors.
+
+### Responsive composition
+
+Reuse the existing direct phone path demonstrated by
+`mobile-plugin-settings-row.spec.ts`: Settings navigation sheet > Plugins >
+full plugin row > detail page. Keep the card inline rather than introducing a
+drawer. On narrow/coarse-pointer layouts, let shortcut rows stack as needed,
+give recorder/reset/clear controls a 44 px active dimension, retain the page's
+single vertical scroll owner, and assert no document horizontal overflow.
+Desktop keeps the denser inline row arrangement.
+
+### Copy and documentation
+
+Add localized, self-documenting copy explaining that plugin shortcuts are
+personal and only run while the plugin is active. Update English, Portuguese,
+Simplified Chinese, and generated Traditional Chinese catalogs, plus the
+pseudo-locale through the existing scripts.
+
+Update `docs/public/plugins.md`, `docs/public/plugins-manifest.md`, and
+`docs/public/plugins-authoring.md`, whose dominant types remain explanation,
+reference, and how-to respectively. Reconcile the maintained
+`docs/plans/plugins/PLUGIN-API.md` contract comment. No public screenshot
+currently shows plugin rows on the core page, so no image replacement is
+required.
+
+## Tests
+
+- `apps/web/components/settings/keyboard-shortcuts-card.test.tsx` proves the
+ global card renders core entries only while still reporting a conflict with
+ a plugin binding.
+- A focused plugin-shortcut card test under
+ `apps/web/components/settings/plugins/` proves selected-owner rendering,
+ no empty card, draft/reset behavior, and shortcut-only persistence without a
+ plugin-config mutation.
+- `apps/web/src/settings-routes.plugin.test.ts` proves an exact plugin detail
+ route replacement retains both the plugin-owned content and host shortcut
+ surface.
+- `apps/web/lib/keyboard/plugin-shortcuts.test.ts` and
+ `apps/web/lib/keyboard/shortcut-conflicts.test.ts` preserve entry identity,
+ labels, defaults, and core/plugin comparison behavior.
+- `apps/web/hooks/use-plugin-shortcuts.test.ts` remains targeted regression
+ evidence that the relocated override still dispatches and core bindings win.
+
+## E2E tests
+
+- Extend `apps/web/e2e/tests/auth/plugin-settings-member.spec.ts` for
+ `AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.1` through `.5`: install as an
+ administrator, sign in as a member, prove the plugin recorder is absent from
+ the global page and present on the plugin detail page while operator controls
+ remain hidden, then save and reload the member's override.
+- Extend
+ `apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts` for
+ `AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.6` and `.7`: enter through the
+ existing full-card tap after disabling the fixture plugin, edit and save its
+ shortcut, reload, and verify touch geometry, internal vertical scrolling,
+ and zero document horizontal overflow. Capture and restore the persisted
+ shortcut baseline so the worker does not leak settings state.
+
+Both scenarios must be written and observed failing for the old placement
+before the production UI changes are made.
+
+## Work orders
+
+- [x] [Task 01: Relocate the plugin shortcut editor](task-01-relocate-plugin-shortcut-editor.md)
+- [x] [Task 02: Update plugin shortcut documentation](task-02-update-plugin-shortcut-documentation.md)
+
+## Verification results
+
+- Task 01 targeted unit, typecheck, lint, i18n, E2E sleep-ratchet, authenticated
+ member E2E, and mobile E2E checks passed.
+- Task 02 scoped documentation searches, public-doc tests/validation, and
+ whitespace checks passed.
+- Review remediation removed unrelated generated locale churn and covered the
+ exact-root plugin settings-route compatibility path.
+- Review remediation rebased shortcut drafts across hydration, live settings
+ updates, and in-flight saves, and made both persistence E2E scenarios await
+ the successful settings response before reload.
+
+## Risks
+
+- The plugin detail page currently groups its configuration UI under an
+ administrator gate; placing the new personal card inside that block would
+ silently remove member access.
+- Separating rendered lists can accidentally narrow conflict detection to the
+ visible page and hide core-versus-plugin or plugin-versus-plugin collisions.
+- Saving still uses the complete-map backend contract, so the contributor must
+ continue rebasing its local delta onto the latest authoritative store map.
+- Mobile recorder rows currently use compact desktop geometry and need an
+ explicit narrow/coarse-pointer composition to avoid clipped actions.
diff --git a/docs/plans/plugin-shortcut-settings/task-01-relocate-plugin-shortcut-editor.md b/docs/plans/plugin-shortcut-settings/task-01-relocate-plugin-shortcut-editor.md
new file mode 100644
index 0000000000..ff6090586a
--- /dev/null
+++ b/docs/plans/plugin-shortcut-settings/task-01-relocate-plugin-shortcut-editor.md
@@ -0,0 +1,147 @@
+---
+id: "01-relocate-plugin-shortcut-editor"
+title: "Relocate the plugin shortcut editor"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001
+acceptance_criteria:
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.1
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.2
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.3
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.4
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.5
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.6
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.7
+system_design:
+ - ../../specs/plugins/system-design/plugin-shortcut-settings.md
+---
+
+# Task 01: Relocate the Plugin Shortcut Editor
+
+## Summary
+
+Move plugin-declared shortcut controls from the global core shortcut list to
+the owning plugin's detail page. Preserve the existing per-user settings and
+dispatch contracts, and prove the permission split plus desktop and phone
+flows with tests written red before implementation.
+
+## In scope
+
+- Add failing component and Playwright assertions for the new placement,
+ member access, persistence, inactive-plugin editing, and mobile geometry
+ before changing production components.
+- Reuse shortcut recorder and conflict logic across a core-only global card
+ and a selected-plugin card.
+- Register a personal plugin-shortcut save contributor outside the
+ administrator-only plugin configuration boundary.
+- Add localized explanatory copy and update all required catalogs.
+- Preserve the existing dispatcher and namespaced override storage.
+
+## Out of scope
+
+- Backend, manifest, registry, SDK, and shortcut-dispatch changes.
+- Plugin-authored settings access, lifecycle authorization, or Settings search.
+- Public documentation changes, which belong to Task 02.
+
+## Acceptance
+
+- The global page renders only Kandev shortcuts, and a plugin detail page
+ renders only that plugin's declared shortcuts for both members and
+ administrators while preserving all operator gates.
+- Recording, clearing, resetting, discarding, and saving use the existing
+ namespaced portable setting; a shortcut-only save does not call plugin
+ configuration or lifecycle APIs, and conflict warnings still use the full
+ core-plus-plugin set.
+- The authenticated desktop scenario and existing mobile plugin-detail
+ scenario pass after proving the old placement fails; phone controls are
+ touch-usable, persistence survives reload, and the document has no
+ horizontal overflow.
+
+## Verification
+
+```bash
+cd apps
+pnpm install --frozen-lockfile
+pnpm --filter @kandev/web test -- components/settings/keyboard-shortcuts-card.test.tsx components/settings/plugins/plugin-shortcuts-card.test.tsx lib/keyboard/plugin-shortcuts.test.ts lib/keyboard/shortcut-conflicts.test.ts hooks/use-plugin-shortcuts.test.ts src/settings-routes.plugin.test.ts
+pnpm --filter @kandev/web typecheck
+pnpm --filter @kandev/web lint -- components/settings/general-settings.tsx components/settings/keyboard-shortcuts-card.tsx components/settings/keyboard-shortcuts-card.test.tsx components/settings/plugins/plugin-detail.tsx components/settings/plugins/plugin-shortcuts-card.tsx components/settings/plugins/plugin-shortcuts-card.test.tsx lib/keyboard/plugin-shortcuts.ts lib/keyboard/plugin-shortcuts.test.ts src/settings-routes.tsx src/settings-routes.plugin.test.ts e2e/tests/auth/plugin-settings-member.spec.ts e2e/tests/plugins/mobile-plugin-settings-row.spec.ts
+pnpm --filter @kandev/web i18n:check
+pnpm --filter @kandev/web i18n:ratchet
+pnpm --filter @kandev/web e2e:sleep-ratchet
+cd web
+pnpm e2e:run --project auth tests/auth/plugin-settings-member.spec.ts
+pnpm e2e:run --project mobile-chrome tests/plugins/mobile-plugin-settings-row.spec.ts
+```
+
+## Files likely touched
+
+- `apps/web/components/settings/general-settings.tsx`
+- `apps/web/components/settings/keyboard-shortcuts-card.tsx`
+- `apps/web/components/settings/keyboard-shortcuts-card.test.tsx`
+- `apps/web/components/settings/plugins/plugin-detail.tsx`
+- `apps/web/components/settings/plugins/plugin-shortcuts-card.tsx`
+- `apps/web/components/settings/plugins/plugin-shortcuts-card.test.tsx`
+- `apps/web/lib/keyboard/plugin-shortcuts.ts`
+- `apps/web/lib/keyboard/plugin-shortcuts.test.ts`
+- `apps/web/src/settings-routes.tsx`
+- `apps/web/src/settings-routes.plugin.test.ts`
+- `apps/web/e2e/tests/auth/plugin-settings-member.spec.ts`
+- `apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts`
+- `apps/web/src/locales/en/plugins.json`
+- `apps/web/src/locales/pt-pt/plugins.json`
+- `apps/web/src/locales/zh-cn/plugins.json`
+- `apps/web/src/locales/zh-hk/plugins.json`
+- `apps/web/src/locales/zh-tw/plugins.json`
+- `apps/web/src/locales/pseudo/plugins.json`
+
+## Dependencies
+
+None.
+
+## Risks
+
+- Hook placement must remain unconditional when a plugin has no keybindings;
+ hide only the rendered card, not hook execution.
+- A full-map user-settings save can overwrite a newer concurrent shortcut
+ edit unless local changes are rebased onto the newest authoritative map and
+ stale responses are prevented from replacing higher store revisions.
+- Auth and mobile Playwright projects use different fixtures and must be run
+ separately so test discovery is not mistaken for passing coverage.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `docs/specs/plugins/requirements/plugin-shortcut-settings.md`
+- `docs/specs/plugins/system-design/plugin-shortcut-settings.md`
+- `apps/web/components/settings/general-settings.tsx`
+- `apps/web/components/settings/keyboard-shortcuts-card.tsx`
+- `apps/web/components/settings/plugins/plugin-detail.tsx`
+- `apps/web/e2e/tests/auth/plugin-settings-member.spec.ts`
+- `apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts`
+
+## Results
+
+- Added and observed the authenticated and mobile Playwright assertions fail
+ against the old placement before implementing the production UI.
+- Moved plugin shortcut rows to the selected plugin detail page, kept the
+ global card core-only, and reused full core-plus-plugin conflict calculation.
+- Added route-local personal shortcut persistence, reset/clear behavior, and
+ touch-sized stacked rows without changing plugin configuration or dispatch.
+- Kept the host shortcut card beside plugin-owned content when a bundle
+ replaces its exact detail route.
+- Added localized English, Portuguese, Simplified Chinese, Traditional
+ Chinese, and pseudo-locale copy.
+- Rebased local shortcut edits and deletions across initial hydration, later
+ portable-settings updates, and in-flight saves; stale responses no longer
+ replace a higher store revision.
+- Hardened desktop and mobile persistence coverage to await the successful
+ settings PATCH and assert the complete non-default binding after reload.
+- Verification passed: targeted Vitest (6 files, 52 tests), web typecheck,
+ targeted ESLint, `i18n:check`, `i18n:ratchet`, `e2e:sleep-ratchet`, the
+ authenticated member E2E (1 passed), and the mobile plugin E2E (1 passed).
diff --git a/docs/plans/plugin-shortcut-settings/task-02-update-plugin-shortcut-documentation.md b/docs/plans/plugin-shortcut-settings/task-02-update-plugin-shortcut-documentation.md
new file mode 100644
index 0000000000..f7962e5ca3
--- /dev/null
+++ b/docs/plans/plugin-shortcut-settings/task-02-update-plugin-shortcut-documentation.md
@@ -0,0 +1,99 @@
+---
+id: "02-update-plugin-shortcut-documentation"
+title: "Update plugin shortcut documentation"
+status: done
+wave: 2
+depends_on:
+ - "01-relocate-plugin-shortcut-editor"
+plan: "plan.md"
+requirements:
+ - REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001
+acceptance_criteria:
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.1
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.2
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.3
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.4
+ - AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.6
+system_design:
+ - ../../specs/plugins/system-design/plugin-shortcut-settings.md
+---
+
+# Task 02: Update Plugin Shortcut Documentation
+
+## Summary
+
+Reconcile public plugin explanation, authoring, manifest reference, and the
+maintained frontend plugin contract with the implemented per-plugin shortcut
+location. Clarify that plugin lifecycle/configuration remains install-wide and
+administrator-owned while shortcut overrides are personal.
+
+## In scope
+
+- Replace plugin-specific references to the global Keyboard Shortcuts page
+ with Settings > Plugins > the installed plugin.
+- Explain the personal shortcut versus administrator configuration boundary.
+- Keep manifest description and `registerKeybinding` reference text aligned.
+- Validate the complete public docs set and internal links.
+
+## Out of scope
+
+- Changing the core shortcut guide or its screenshot.
+- Adding a new public page, diagram, or screenshot.
+- Restating manifest grammar or dispatch rules beyond the location correction.
+
+## Acceptance
+
+- Plugin users and authors are consistently directed to the installed
+ plugin's detail page for shortcut overrides, and the permission distinction
+ is explicit where plugin management is described.
+- The maintained plugin API contract and manifest reference no longer claim
+ that plugin binding descriptions or overrides live on the global Keyboard
+ Shortcuts page.
+- Public docs validation and whitespace checks pass without a new screenshot
+ or navigation entry.
+
+## Verification
+
+```bash
+rg -n "Settings > Plugins" docs/public/plugins.md docs/public/plugins-manifest.md docs/public/plugins-authoring.md docs/plans/plugins/PLUGIN-API.md
+! rg -n 'Settings > (\*\*)?Keyboard Shortcuts' docs/public/plugins.md docs/public/plugins-manifest.md docs/public/plugins-authoring.md docs/plans/plugins/PLUGIN-API.md
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+git diff --check -- docs/public docs/plans/plugins/PLUGIN-API.md
+```
+
+## Files likely touched
+
+- `docs/public/plugins.md`
+- `docs/public/plugins-manifest.md`
+- `docs/public/plugins-authoring.md`
+- `docs/plans/plugins/PLUGIN-API.md`
+
+## Dependencies
+
+- Task 01 must establish the final rendered location and permission behavior.
+
+## Risks
+
+- General shortcut documentation still legitimately names the core Keyboard
+ Shortcuts page; checks must stay scoped to plugin-owned references.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `docs/specs/plugins/requirements/plugin-shortcut-settings.md`
+- `docs/specs/plugins/system-design/plugin-shortcut-settings.md`
+- The completed Task 01 UI and E2E evidence.
+
+## Results
+
+- Updated the public plugin overview, manifest reference, authoring guide, and
+ maintained frontend plugin API contract to point users to the installed
+ plugin detail page and distinguish personal shortcuts from administrator
+ configuration and lifecycle controls.
+- Public documentation checks passed: scoped location/reference searches,
+ `node --test scripts/validate-public-docs.test.mjs`,
+ `node scripts/validate-public-docs.mjs`, and `git diff --check`.
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index 8d90cb0dfb..abb30900a0 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -755,7 +755,9 @@ interface PluginRegistry {
registerNavItem(item: NavItem): void;
// Route under /settings/plugins/{id}/... rendered inside settings shell.
- // The settings shell already provides its own topbar chrome — no options here.
+ // The exact /settings/plugins/{id} route retains the host-owned personal
+ // shortcut card beside the plugin component; nested routes are fully
+ // plugin-owned. The settings shell already provides its own topbar chrome.
registerSettingsRoute(path: string, Component: React.ComponentType): void;
// Native Settings > Integrations contribution. The host adds index/navigation
@@ -842,8 +844,10 @@ interface PluginRegistry {
// ctrl/cmd/mod/alt modifier, and the dispatcher re-checks the *resolved*
// combo, so a user override that drops the modifier silently falls back to
// skipping editables rather than swallowing ordinary keystrokes. Combos are
- // user-overridable in Settings > Keyboard Shortcuts, namespaced
- // `plugin:{pluginId}:{id}`. Binding an id the manifest didn't declare still
+ // user-overridable on the installed plugin detail page (Settings > Plugins >
+ // ), namespaced `plugin:{pluginId}:{id}`. Overrides are personal to
+ // the signed-in user; plugin configuration and lifecycle controls remain
+ // administrator-only. Binding an id the manifest didn't declare still
// stores the handler (a console warning is logged) since the dispatcher's
// effective-shortcut resolution keys off the manifest list.
//
diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md
index d272f360f1..9d64acbf53 100644
--- a/docs/public/plugins-authoring.md
+++ b/docs/public/plugins-authoring.md
@@ -254,6 +254,15 @@ state after load or when the workspace list changes. If a required read is
missing, extend this typed context contract; do not reverse-engineer Zustand
slice shapes in a released plugin.
+Users configure a plugin's declared shortcuts on that installed plugin's detail
+page at **Settings > Plugins > ``**. These overrides are personal to
+the signed-in user. The plugin's schema-driven configuration and lifecycle
+controls remain install-wide and administrator-only.
+
+If a bundle registers a component at its exact plugin detail route, Kandev
+keeps that component and renders the host-owned shortcut card alongside it.
+Nested plugin settings routes remain fully plugin-owned.
+
### Frontend hook/API matrix
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
@@ -263,7 +272,7 @@ slice shapes in a released plugin.
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins//...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
-| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; core shortcuts win, and editable targets are skipped unless that entry set ui.keybindings[].allow_in_editor | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
+| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo on the installed plugin detail page at **Settings > Plugins > ``** | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; core shortcuts win, and editable targets are skipped unless that entry set ui.keybindings[].allow_in_editor | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
| registerIntegrationSettings | One provider-owned settings component with an optional action mounted in the detail header and the integrations index card | Active ui.bundle | Registration is exclusive by id and revoked on unload; host owns workspace selection and settings navigation; component and action receive the routed `workspaceId`, and action receives its `surface` | registry.registerIntegrationSettings({ id: "acme", Component, action: Toggle }) |
| registerTranslations | Flat English fallback plus optional Kandev locale catalogs, isolated to this plugin's namespace | Active ui.bundle | Catalogs are replaced atomically, removed on unload, and registry consumers invalidate when the host locale changes | registry.registerTranslations({ en: { settings: "Settings" }, "pt-pt": { settings: "Definições" } }) |
| registerRepositoryProvider | Provider-owned paged/searchable repository list, URL match/inspect, branches, and optional native `createChangeRequest` transport | ui.bundle and matching `repository_providers[]` id | Registration and in-flight callbacks are result-fenced on unload; host owns native task and Create PR UI | registry.registerRepositoryProvider({ id: "acme", ...provider }) |
diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md
index 612088cbd9..d94d0265c2 100644
--- a/docs/public/plugins-manifest.md
+++ b/docs/public/plugins-manifest.md
@@ -198,7 +198,7 @@ ui: # optional native frontend plugin
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
| `ui.keybindings[].id` | yes\* | string | Stable, plugin-local slug (_required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]_$`and be unique within this plugin's own`ui.keybindings`list, not globally; the effective shortcut is namespaced`plugin:{pluginId}:{id}`. |
| `ui.keybindings[].default` | yes\* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key; the browser reports the shifted glyph for those keys, so the combo could never match. |
-| `ui.keybindings[].description` | yes\* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
+| `ui.keybindings[].description` | yes\* | string | Non-empty, human-readable label shown on the installed plugin's detail page at **Settings > Plugins > ``**, where each user can configure a personal shortcut override. |
| `ui.keybindings[].allow_in_editor` | no | boolean | Lets this binding fire while an `input`, `textarea` or contenteditable holds focus. Defaults to `false`, so a plugin cannot shadow ordinary typing. Only accepted on a combo carrying a `ctrl`/`cmd`/`mod`/`alt` modifier; `shift` alone does not count. Kandev re-applies that requirement to the effective combo, so a user override that drops the modifier reverts to skip-while-typing behavior. |
`ui.pages` is declarative manifest metadata only and is not currently rendered
diff --git a/docs/public/plugins.md b/docs/public/plugins.md
index db21f2eea3..5c377528ac 100644
--- a/docs/public/plugins.md
+++ b/docs/public/plugins.md
@@ -51,10 +51,12 @@ plugin is installed and active. Bundles can also inject components into
host-defined slots, including **icon buttons in the chat composer toolbar**
(beside the model picker, mic, and send button), so an active plugin can add
its own action right where you message an agent. A bundle can also declare
-**keybindings** (user-overridable at **Settings > Keyboard Shortcuts**, with
-core shortcuts always winning on a conflict) and open host-owned **modal
-windows** from anywhere in its code: see [Authoring a
-plugin](plugins-authoring.md) for both.
+**keybindings** (user-overridable on the installed plugin's detail page at
+**Settings > Plugins > ``**, with core shortcuts always winning on a
+conflict) and open host-owned **modal windows** from anywhere in its code: see
+[Authoring a plugin](plugins-authoring.md) for both. Shortcut overrides are
+personal to each user; plugin configuration and lifecycle controls remain
+administrator-only.
### Global Status contributions
diff --git a/docs/specs/plugins/README.md b/docs/specs/plugins/README.md
index 9f83b33c31..d02df0fdc0 100644
--- a/docs/specs/plugins/README.md
+++ b/docs/specs/plugins/README.md
@@ -37,6 +37,7 @@ plugin security boundaries.
- [Plugin Authoring Experience](requirements/authoring-experience.md)
- [Plugin Marketplace](requirements/marketplace.md)
- [Plugin nav items in the sidebar footer icon row](requirements/plugin-nav-sidebar-footer.md)
+- [Plugin Shortcut Settings](requirements/plugin-shortcut-settings.md)
- [Plugin System](requirements/plugins.md)
- [Plugin Repository Task Creation](requirements/repository-provider-task-creation.md)
- [Voice Plugin Host Prerequisites](requirements/voice-extraction-host.md)
@@ -52,6 +53,7 @@ plugin security boundaries.
- [Plugin nav items in the sidebar footer icon row System Design Part 2](system-design/plugin-nav-sidebar-footer-02.md)
- [Plugin nav items in the sidebar footer icon row System Design Part 3](system-design/plugin-nav-sidebar-footer-03.md)
- [Plugin nav items in the sidebar footer icon row System Design Part 4](system-design/plugin-nav-sidebar-footer-04.md)
+- [Plugin Shortcut Settings](system-design/plugin-shortcut-settings.md)
- [Plugin System System Design Part 1](system-design/plugins-01.md)
- [Plugin System System Design Part 2](system-design/plugins-02.md)
- [Plugin System System Design Part 3](system-design/plugins-03.md)
diff --git a/docs/specs/plugins/requirements/plugin-shortcut-settings.md b/docs/specs/plugins/requirements/plugin-shortcut-settings.md
new file mode 100644
index 0000000000..d02038f960
--- /dev/null
+++ b/docs/specs/plugins/requirements/plugin-shortcut-settings.md
@@ -0,0 +1,76 @@
+---
+status: active
+system: plugins
+created: 2026-09-04
+owners:
+ - kandev
+---
+# Plugin Shortcut Settings Requirements
+
+## Overview
+
+Plugin shortcut declarations belong to an installed plugin, but their controls
+currently appear among Kandev's core shortcuts. This separates a plugin's
+shortcut from the rest of that plugin's settings and makes a per-user
+preference look like install-wide operator configuration. The plugin system
+owns the declaration, identity, and configuration location; the UI system's
+shared settings-save behavior remains an independent dependency.
+
+## Requirements
+
+### REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001: Plugin-owned shortcut configuration
+
+**Intent:** Keep each plugin's configurable shortcuts with that plugin while
+preserving personal overrides, conflict visibility, and core-shortcut
+precedence.
+
+**User story:** As a Kandev user, I want to configure a plugin's shortcuts from
+that plugin's settings page, so that I can find the controls where I manage the
+plugin without mixing them into Kandev's core shortcuts.
+
+#### Acceptance criteria
+
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.1:** When an installed plugin
+ declares one or more `ui.keybindings`, its detail page shall show an inline
+ shortcut editor containing only that plugin's declared bindings, and the
+ Preferences > Keyboard Shortcuts page shall show only Kandev-owned bindings.
+ The host-owned editor shall remain present when an active plugin registers a
+ replacement component at its exact detail route.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.2:** Every user who can open the
+ installed plugin's detail page shall be able to edit their own shortcut
+ overrides without administrator permission; plugin configuration, enable,
+ disable, update, and uninstall controls shall retain their existing
+ administrator-only authorization.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.3:** Recording, clearing, or
+ resetting a plugin shortcut shall change a route-local draft and use the
+ shared Settings save action. Saving only a shortcut shall not restart,
+ enable, disable, or otherwise mutate the plugin installation.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.4:** The relocated editor shall
+ read and write the existing namespaced per-user override identity
+ `plugin:{pluginId}:{keybindingId}`. Existing overrides shall remain effective
+ and visible after the relocation, a reload, and normal portable-settings
+ synchronization, without a data migration.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.5:** Conflict warnings on both the
+ core shortcut page and a plugin shortcut editor shall compare the effective
+ bindings of all Kandev-owned shortcuts and all installed plugin declarations,
+ name the other conflicting actions, and preserve the rule that a Kandev-owned
+ shortcut wins during dispatch.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.6:** A disabled or errored plugin's
+ declared shortcuts shall remain visible and configurable on its detail page,
+ while the page makes clear that they run only while the plugin is active. A
+ plugin with no declared keybindings shall not render an empty shortcut card.
+- **AC-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001.7:** On a phone, the shortcut editor
+ shall be reachable through Settings > Plugins > the installed plugin, retain
+ the detail page as the single vertical scroll owner, keep recording and
+ reset controls touch-usable without horizontal overflow, and complete the
+ same save-and-reload outcome as the desktop surface.
+
+## Out of scope
+
+- Changing the `ui.keybindings` manifest grammar, `registerKeybinding`, editor
+ opt-in behavior, dispatch order, or core-shortcut precedence.
+- Moving plugin-authored settings slots or install-wide plugin configuration
+ across the existing user and administrator authorization boundary.
+- Adding plugin-authored fields or individual plugin shortcuts to global
+ Settings search results.
+- Deleting orphaned per-user override keys when a plugin is uninstalled.
diff --git a/docs/specs/plugins/requirements/voice-extraction.md b/docs/specs/plugins/requirements/voice-extraction.md
index ed74a65ff9..8bd893b31b 100644
--- a/docs/specs/plugins/requirements/voice-extraction.md
+++ b/docs/specs/plugins/requirements/voice-extraction.md
@@ -66,7 +66,7 @@ existing user has to do.
| `voice.openAIApiKey` / `KANDEV_VOICE_OPENAI_API_KEY` | the plugin's own **OpenAI API key** setting |
| `userSettings.voiceMode` (backend model, DTO, store, boot payload, frontend types) | the plugin's per-user `host.storage` entry |
| The Voice Mode section of **Settings > Task Behavior** | **Settings > Plugins > Voice Mode** |
-| The `VOICE_INPUT_TOGGLE` core shortcut | the plugin keybinding, listed and rebindable in the same **Settings > Keyboard shortcuts** page |
+| The `VOICE_INPUT_TOGGLE` core shortcut | the plugin keybinding, with its configuration surface governed by [Plugin Shortcut Settings](plugin-shortcut-settings.md) |
| `@huggingface/transformers` in `apps/web` | bundled inside the plugin's Whisper worker |
### Migration
diff --git a/docs/specs/plugins/system-design/plugin-shortcut-settings.md b/docs/specs/plugins/system-design/plugin-shortcut-settings.md
new file mode 100644
index 0000000000..54882e1444
--- /dev/null
+++ b/docs/specs/plugins/system-design/plugin-shortcut-settings.md
@@ -0,0 +1,171 @@
+---
+status: current
+system: plugins
+requirements:
+ - REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001
+created: 2026-09-04
+owners:
+ - kandev
+---
+# Plugin Shortcut Settings System Design
+
+## Purpose and boundaries
+
+The plugin system owns plugin keybinding declarations and their namespaced
+override identities, so it also owns where users configure those bindings.
+This design moves their presentation from the core shortcut list to each
+installed plugin's existing detail route without changing the manifest,
+frontend registry, dispatcher, HTTP settings contract, or backend storage.
+
+The UI system continues to own the shared settings-save coordinator described
+by [Settings Manual Save](../../ui/requirements/settings-manual-save.md).
+Portable persistence continues to follow
+[ADR 0041](../../../decisions/0041-backend-owned-portable-user-settings.md).
+
+## Requirement mapping
+
+| Requirement | Design sections |
+| --- | --- |
+| `REQ-PLUGINS-PLUGIN-SHORTCUT-SETTINGS-001` | [Settings surfaces](#settings-surfaces), [Draft and save flow](#draft-and-save-flow), [Conflict and dispatch behavior](#conflict-and-dispatch-behavior), [Responsive behavior](#responsive-behavior), [Failure and compatibility](#failure-and-compatibility) |
+
+## Settings surfaces
+
+`KeyboardShortcutsSettings` in
+`apps/web/components/settings/general-settings.tsx` continues to load all
+installed plugin declarations for conflict calculation, but
+`KeyboardShortcutsCard` renders only the static entries from
+`CONFIGURABLE_SHORTCUTS`. It no longer renders a dynamic Plugin Shortcuts
+group.
+
+`PluginDetail` in
+`apps/web/components/settings/plugins/plugin-detail.tsx` adds a host-owned
+plugin-shortcut card between the plugin's operator configuration area and its
+manifest card. The card renders only when the selected `PluginRecord` has at
+least one parseable `ui.keybindings` declaration. It remains outside the
+`canManage` gate so a member can manage a personal shortcut while the existing
+plugin-authored settings slot, schema-driven configuration, lifecycle actions,
+and danger zone remain administrator-only.
+
+An active bundle may register a replacement component at the exact
+`/settings/plugins/{id}` route. `SettingsRoutes` keeps that plugin-owned
+content but wraps it in `PluginRootSettingsRoute`, which appends the same
+host-owned shortcut card. Nested plugin settings routes remain plugin-owned
+without the card. This preserves the public route extension while ensuring a
+plugin cannot displace the user's only shortcut configuration surface.
+
+A focused component under
+`apps/web/components/settings/plugins/` owns the plugin shortcut draft and
+card. It reuses the existing `ShortcutRecorder` behavior and shared conflict
+label derivation from the keyboard-shortcut components instead of copying the
+recording, clear, reset, dirty, or warning logic. Within the plugin detail
+context, a row displays the manifest keybinding description without repeating
+the plugin name; conflict labels stay qualified by plugin name so warnings are
+unambiguous.
+
+The card's visible description explains that these shortcuts are personal and
+run only while the plugin is active. A disabled or errored plugin therefore
+keeps the editor available without implying that its handler is currently
+registered.
+
+## Data and contracts
+
+`PluginRecord.ui.keybindings` remains the declaration source. The helpers in
+`apps/web/lib/keyboard/plugin-shortcuts.ts` continue to parse each manifest
+default and derive the stable override key
+`plugin:{pluginId}:{keybindingId}`. `StoredShortcutOverrides` remains one map
+containing both core and plugin entries under
+`userSettings.keyboardShortcuts`.
+
+No backend DTO or endpoint changes. The editor persists through the existing
+user-settings update request's `keyboard_shortcuts` field. Plugin configuration
+continues to use the plugin config endpoint and is a separate save contributor.
+
+## Draft and save flow
+
+1. The plugin detail route reads the selected plugin plus the complete installed
+ plugin list through `usePlugins`, and reads the current portable shortcut
+ overrides from the settings store.
+2. The shortcut component builds all plugin entries for conflict comparison,
+ filters the visible list by the selected plugin ID, and seeds saved and draft
+ override snapshots from `userSettings.keyboardShortcuts`. Initial hydration
+ and later higher-revision settings updates replace the saved baseline and
+ rebase locally changed or deleted keys onto that complete incoming map.
+3. A recorder mutation changes only the local draft. The component registers a
+ contributor such as `plugin-shortcuts:{pluginId}` with
+ `useSettingsSaveContributor`; discard restores the saved snapshot. A draft
+ cannot become saveable until the initial portable-settings baseline has been
+ incorporated.
+4. Save rebases the local delta onto the latest settings-store map immediately
+ before sending the complete override map through `updateUserSettings`. A
+ response advances the baseline only when its revision is not older than the
+ store, and edits made while the request is pending remain in the draft.
+5. If an administrator also changed schema-driven plugin configuration, the
+ route coordinator invokes both independent contributors. Existing partial
+ success and retry behavior applies; a shortcut-only save never calls the
+ plugin config endpoint and therefore never restarts the plugin.
+
+## Conflict and dispatch behavior
+
+Both shortcut surfaces resolve conflicts from the union of translated core
+entries and every installed plugin entry using
+`findShortcutConflicts`. Each surface filters only the rows it displays, not
+the comparison set. This retains core-versus-plugin and plugin-versus-plugin
+warnings after the rows move to separate pages.
+
+`usePluginShortcuts` is unchanged. It reads the same override map on each
+keydown, invokes only currently registered plugin handlers, skips disallowed
+editable targets, and suppresses a plugin handler when its effective combo
+matches a Kandev-owned shortcut. Disabled and errored plugins have no live
+handler registration even though their saved declaration and override remain
+editable.
+
+## Responsive behavior
+
+Desktop keeps the existing direct plugin detail route and adds the shortcut
+card inline in that page. On phones, the entry path remains the shipped
+Settings navigation sheet, Plugins list, and full-card tap into the direct
+plugin detail route, following
+`apps/web/e2e/tests/plugins/mobile-plugin-settings-row.spec.ts`. A drawer is
+not introduced because the shortcut list is persistent detail content rather
+than a temporary choice.
+
+The detail page's settings scroll container remains the single vertical scroll
+owner. Shortcut rows can stack their label and controls on narrow widths;
+record, clear, and reset actions provide at least a 44 px active touch
+dimension on phone/coarse-pointer presentation. The shared floating save
+surface remains the primary persistence action and retains its existing
+dynamic-viewport and safe-area behavior. Shared manifest data, drafts,
+conflict calculation, and save callbacks are identical across viewports;
+only row composition and touch geometry vary.
+
+## Failure and compatibility
+
+An unparseable manifest default remains a skipped declaration with the existing
+developer warning; backend manifest validation should make this exceptional.
+If no parseable bindings remain, no empty card is rendered. A failed
+user-settings save leaves the contributor dirty and retryable under the shared
+save coordinator.
+
+Namespaced override IDs and the backend field do not change, so the relocation
+requires no migration and does not reset existing user choices. Uninstalling a
+plugin continues to make any orphaned override inert; reinstalling the same
+plugin identity can resolve that key again under existing behavior.
+
+## Verification
+
+Component tests cover core-only rendering, selected-plugin rendering, empty
+declarations, qualified conflict warnings, drafts, resets, route replacement,
+and persistence boundaries. Deferred-response cases cover initial hydration,
+higher-revision synchronization, complete-map rebasing, stale save responses,
+and edits made during an in-flight save. The existing authenticated member
+plugin-settings scenario proves that the shortcut editor remains available
+while operator controls stay hidden and that the General shortcut page no
+longer contains the plugin row. The existing mobile plugin-detail scenario
+proves direct entry, touch geometry, single-axis containment, an acknowledged
+Save, and reload while the fixture plugin is disabled. Existing plugin
+dispatcher tests remain the regression evidence for dispatch precedence and
+editor-target rules.
+
+## Related decisions
+
+- [ADR 0041: Backend-Owned Portable User Settings](../../../decisions/0041-backend-owned-portable-user-settings.md)
diff --git a/docs/specs/plugins/system-design/plugins-03.md b/docs/specs/plugins/system-design/plugins-03.md
index 1ad3856230..bde0749b16 100644
--- a/docs/specs/plugins/system-design/plugins-03.md
+++ b/docs/specs/plugins/system-design/plugins-03.md
@@ -68,6 +68,9 @@ Mattermost-webapp model), not iframes. The full contract lives in
A status slot chooses the contribution's default side; portable user order may
move either contribution across the desktop spacer and determines drawer order.
Registrations are namespaced per plugin and bulk-revoked on disable/uninstall.
+ An exact plugin-detail settings route replaces the native detail content but
+ retains the host-owned personal shortcut card; nested settings routes remain
+ fully plugin-owned.
- **Isolation (v1):** only active, operator-installed plugins load; a failing
bundle/`initialize` is caught and never breaks boot; slot components render behind
error boundaries. Plugin JS otherwise runs with full in-origin store access —
@@ -75,11 +78,12 @@ Mattermost-webapp model), not iframes. The full contract lives in
- **Keybindings:** a plugin declares `ui.keybindings: [{ id, default, description }]`
in its manifest (`default` is a combo string like `mod+shift+k`, validated at
registration time). `registry.registerKeybinding(id, handler)` binds a JS handler
- to a declared id; the host resolves the effective combo (a user override from
- Settings > Keyboard Shortcuts if set, else the manifest `default`) and dispatches
- it globally, skipping editable targets the same way core app shortcuts do. User
- overrides are namespaced `plugin:{pluginId}:{id}` so they survive independently
- per plugin.
+ to a declared id; the host resolves the effective combo from the user's override
+ or the manifest `default` and dispatches it globally, skipping editable targets
+ the same way core app shortcuts do. User overrides are namespaced
+ `plugin:{pluginId}:{id}` so they survive independently per plugin. The
+ configuration location, user/operator boundary, and cross-surface conflict
+ behavior are governed by [Plugin Shortcut Settings](plugin-shortcut-settings.md).
- **Modals and drawers:** `host.openModal({ title?, description?, content, size?, dismissible?, presentation? })` imperatively
opens a modal rendered by the host's `` (mounted once inside the
authenticated `AppShell` theme/tooltip/toast provider tree and isolated behind its