diff --git a/apps/web/components/settings/keyboard-shortcuts-card.test.tsx b/apps/web/components/settings/keyboard-shortcuts-card.test.tsx index dd00e627b9..2d93483cb9 100644 --- a/apps/web/components/settings/keyboard-shortcuts-card.test.tsx +++ b/apps/web/components/settings/keyboard-shortcuts-card.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ShortcutEntry } from "@/lib/keyboard/plugin-shortcuts"; import { KeyboardShortcutsCard } from "./keyboard-shortcuts-card"; vi.mock("@kandev/ui/kbd", () => ({ @@ -10,6 +11,26 @@ vi.mock("@kandev/ui/kbd", () => ({ afterEach(() => cleanup()); describe("KeyboardShortcutsCard", () => { + it("renders core rows only while reporting conflicts with plugin bindings", () => { + const onChange = vi.fn(); + const pluginEntry: ShortcutEntry = { + source: "plugin", + id: "plugin:session-cost:open-panel", + label: "Session Cost: Open panel", + default: { key: "k", modifiers: { ctrlOrCmd: true } }, + pluginId: "session-cost", + keybindingId: "open-panel", + }; + + render( + , + ); + + expect(screen.getByTestId("shortcut-recorder-SEARCH")).toBeTruthy(); + expect(screen.queryByTestId(`shortcut-recorder-${pluginEntry.id}`)).toBeNull(); + expect(screen.getByTitle("Same shortcut as: Session Cost: Open panel")).toBeTruthy(); + }); + it("updates its route draft without owning persistence", () => { const onChange = vi.fn(); render(); diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx index 0072235aa7..1239f980fa 100644 --- a/apps/web/components/settings/keyboard-shortcuts-card.tsx +++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx @@ -40,6 +40,7 @@ type ShortcutRecorderProps = { onClear?: (id: string) => void; isDirty?: boolean; conflictsWith?: string[]; + touchSized?: boolean; }; export function ShortcutRecorder({ @@ -52,6 +53,7 @@ export function ShortcutRecorder({ onClear, isDirty = false, conflictsWith, + touchSized = false, }: ShortcutRecorderProps) { const [recording, setRecording] = useState(false); const isDefault = JSON.stringify(current) === JSON.stringify(defaultShortcut); @@ -99,14 +101,22 @@ export function ShortcutRecorder({ }, [recording]); return ( -
+
-
+
@@ -136,7 +147,7 @@ function ShortcutRecorderLabel({ }) { const { t } = useTranslation(); return ( - + {label} {conflictsWith && conflictsWith.length > 0 && ( void; onClear?: (id: string) => void; + touchSized: boolean; }) { const { t } = useTranslation(); return ( @@ -172,7 +185,7 @@ function ShortcutRecorderActions({
- {pluginEntries.length > 0 && ( -
-

- {t("settings:pluginShortcuts")} -

-
- {pluginEntries.map((entry) => ( - - ))} -
-
- )}

{t("settings:clickAShortcutToRecordA")}

diff --git a/apps/web/components/settings/plugins/plugin-detail.tsx b/apps/web/components/settings/plugins/plugin-detail.tsx index 336ef3dfef..6ae4bc05ae 100644 --- a/apps/web/components/settings/plugins/plugin-detail.tsx +++ b/apps/web/components/settings/plugins/plugin-detail.tsx @@ -17,6 +17,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { PluginConfigForm } from "./plugin-config-form"; import { PluginManifestCard } from "./plugin-manifest-card"; +import { PluginShortcutsCard } from "./plugin-shortcuts-card"; import { PluginRepoLink } from "./plugin-repo-link"; import { PluginStatusBadge } from "./plugin-status-badge"; import { PluginErrorDiagnostic } from "./plugin-error-diagnostic"; @@ -79,6 +80,7 @@ export function PluginDetail({ pluginId }: { pluginId: string }) { /> )} + {canManage && ( diff --git a/apps/web/components/settings/plugins/plugin-shortcuts-card.test.tsx b/apps/web/components/settings/plugins/plugin-shortcuts-card.test.tsx new file mode 100644 index 0000000000..8f92501f9a --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-shortcuts-card.test.tsx @@ -0,0 +1,389 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginRecord } from "@/lib/types/plugins"; +import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; +import { SettingsSaveProvider } from "../settings-save-provider"; +import { PluginShortcutsCard } from "./plugin-shortcuts-card"; + +const apiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() })); +const storeMocks = vi.hoisted(() => ({ + state: {} as Record, + setUserSettings: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + updateUserSettings: (...args: unknown[]) => apiMocks.updateUserSettings(...args), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector(storeMocks.state), + useAppStoreApi: () => ({ getState: () => storeMocks.state }), +})); + +vi.mock("@kandev/ui/kbd", () => ({ + Kbd: ({ children }: { children: ReactNode }) => {children}, +})); + +const PLUGIN_ID = "session-cost"; +const SHORTCUT_ID = `plugin:${PLUGIN_ID}:open-panel`; +const SAVE_CHANGES = "Save changes"; + +function plugin(overrides: Partial = {}): PluginRecord { + return { + id: PLUGIN_ID, + api_version: 1, + version: "1.0.0", + display_name: "Session Cost", + description: "", + author: "", + categories: [], + capabilities: {}, + status: "disabled", + install_path: "", + signed: true, + installed_at: "", + restart_count: 0, + ui: { + bundle: "ui/bundle.js", + keybindings: [{ id: "open-panel", default: "mod+u", description: "Open panel" }], + }, + ...overrides, + }; +} + +function renderCard(selected: PluginRecord, plugins: PluginRecord[] = [selected]) { + return render( + + + , + ); +} + +function rerenderCard( + rerender: ReturnType["rerender"], + selected: PluginRecord, + plugins: PluginRecord[] = [selected], +) { + rerender( + + + , + ); +} + +function updateStoreUserSettings(patch: Partial) { + const current = storeMocks.state.userSettings as UserSettingsState; + storeMocks.state = { + ...storeMocks.state, + userSettings: { ...current, ...patch }, + }; +} + +function deferredResponse() { + let resolve!: (value: { + settings: { + keyboard_shortcuts: Record; + revision: number; + }; + }) => void; + const promise = new Promise<{ + settings: { + keyboard_shortcuts: Record; + revision: number; + }; + }>((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +beforeEach(() => { + apiMocks.updateUserSettings.mockReset(); + apiMocks.updateUserSettings.mockImplementation( + async ({ keyboard_shortcuts }: { keyboard_shortcuts: Record }) => ({ + settings: { keyboard_shortcuts, revision: 2 }, + }), + ); + storeMocks.setUserSettings.mockReset(); + storeMocks.state = { + userSettings: { + ...defaultSettingsState.userSettings, + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "u", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + loaded: true, + revision: 1, + }, + setUserSettings: storeMocks.setUserSettings, + }; +}); + +afterEach(cleanup); + +describe("PluginShortcutsCard", () => { + it("renders only the selected plugin and keeps other plugin names in conflict labels", () => { + const other = plugin({ + id: "other-plugin", + display_name: "Other Plugin", + ui: { + bundle: "ui/bundle.js", + keybindings: [{ id: "open-panel", default: "mod+u", description: "Open panel" }], + }, + }); + + renderCard(plugin(), [plugin(), other]); + + expect(screen.getByTestId("plugin-shortcuts-card")).toBeTruthy(); + expect(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)).toBeTruthy(); + expect(screen.queryByTestId("shortcut-recorder-plugin:other-plugin:open-panel")).toBeNull(); + expect(screen.getByText("Open panel")).toBeTruthy(); + expect(screen.getByTitle("Same shortcut as: Other Plugin: Open panel")).toBeTruthy(); + }); + + it("keeps no empty card for a plugin without parseable declarations", () => { + renderCard( + plugin({ + ui: { + bundle: "ui/bundle.js", + keybindings: [{ id: "bad", default: "banana", description: "Bad" }], + }, + }), + ); + + expect(screen.queryByTestId("plugin-shortcuts-card")).toBeNull(); + }); + + it("saves the complete override map through user settings only", async () => { + renderCard(plugin()); + + const recorder = screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`); + fireEvent.click(recorder); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + + expect(apiMocks.updateUserSettings).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + }); + expect(storeMocks.setUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + }), + ); + }); +}); + +describe("PluginShortcutsCard hydration", () => { + it("waits for hydration and rebases a pre-hydration edit onto the loaded map", async () => { + updateStoreUserSettings({ keyboardShortcuts: {}, loaded: false, revision: null }); + const selected = plugin(); + const { rerender } = renderCard(selected); + + fireEvent.click(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + + expect(screen.queryByRole("button", { name: SAVE_CHANGES })).toBeNull(); + + updateStoreUserSettings({ + keyboardShortcuts: { + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + }, + loaded: true, + revision: 4, + }); + rerenderCard(rerender, selected); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + }, + }); + }); + + it("adopts a newer complete map before saving a later local edit", async () => { + const selected = plugin(); + const { rerender } = renderCard(selected); + + updateStoreUserSettings({ + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "r", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + revision: 2, + }); + rerenderCard(rerender, selected); + + fireEvent.click(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + }); + }); + + it("preserves a local reset while adopting a newer complete map", async () => { + const selected = plugin(); + const { rerender } = renderCard(selected); + + fireEvent.click(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + updateStoreUserSettings({ + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "r", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + revision: 2, + }); + rerenderCard(rerender, selected); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + }); + }); +}); + +describe("PluginShortcutsCard synchronization", () => { + it("preserves a dirty shortcut while adopting unrelated newer entries", async () => { + const selected = plugin(); + const { rerender } = renderCard(selected); + + fireEvent.click(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + + updateStoreUserSettings({ + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "r", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + revision: 2, + }); + rerenderCard(rerender, selected); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + SEARCH: { key: "k", modifiers: { ctrlOrCmd: true } }, + }, + }); + }); + + it("keeps edits made while a save is in flight dirty after the response", async () => { + const deferred = deferredResponse(); + apiMocks.updateUserSettings.mockReturnValueOnce(deferred.promise); + renderCard(plugin()); + + const recorder = screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`); + fireEvent.click(recorder); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + + fireEvent.click(recorder); + fireEvent.keyDown(window, { key: "v", ctrlKey: true }); + deferred.resolve({ + settings: { + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + revision: 2, + }, + }); + + await waitFor(() => expect(recorder.textContent).toBe("Ctrl+V")); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledTimes(2)); + expect(apiMocks.updateUserSettings).toHaveBeenLastCalledWith({ + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "v", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + }); + }); + + it("does not let an older save response replace a newer store revision", async () => { + const deferred = deferredResponse(); + apiMocks.updateUserSettings.mockReturnValueOnce(deferred.promise); + const selected = plugin(); + const { rerender } = renderCard(selected); + + const recorder = screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`); + fireEvent.click(recorder); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + + updateStoreUserSettings({ + keyboardShortcuts: { + [SHORTCUT_ID]: { key: "r", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "y" }, + }, + revision: 3, + }); + rerenderCard(rerender, selected); + deferred.resolve({ + settings: { + keyboard_shortcuts: { + [SHORTCUT_ID]: { key: "t", modifiers: { ctrlOrCmd: true } }, + "plugin:other:keep": { key: "x" }, + }, + revision: 2, + }, + }); + + await waitFor(() => expect(recorder.textContent).toBe("Ctrl+R")); + expect(storeMocks.setUserSettings).not.toHaveBeenCalled(); + }); +}); + +describe("PluginShortcutsCard reset", () => { + it("resets a customized selected-plugin shortcut in the local draft", async () => { + renderCard(plugin()); + + fireEvent.click(screen.getByTestId(`shortcut-recorder-${SHORTCUT_ID}`)); + fireEvent.keyDown(window, { key: "t", ctrlKey: true }); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + + expect(apiMocks.updateUserSettings).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ + keyboard_shortcuts: { "plugin:other:keep": { key: "x" } }, + }); + }); +}); diff --git a/apps/web/components/settings/plugins/plugin-shortcuts-card.tsx b/apps/web/components/settings/plugins/plugin-shortcuts-card.tsx new file mode 100644 index 0000000000..7893ca005d --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-shortcuts-card.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { PluginRecord } from "@/lib/types/plugins"; +import { + buildPluginShortcutEntries, + resolveShortcutEntry, + type ShortcutEntry, +} from "@/lib/keyboard/plugin-shortcuts"; +import type { KeyboardShortcut } from "@/lib/keyboard/constants"; +import { UNBOUND_SHORTCUT, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; +import { + ShortcutRecorder, + useShortcutConflictLabels, +} from "@/components/settings/keyboard-shortcuts-card"; +import { SettingsCard } from "../settings-card"; +import { usePluginShortcutDraft } from "./use-plugin-shortcut-draft"; + +type PluginShortcutEntry = Extract; + +export function PluginShortcutsCard({ + plugin, + plugins, +}: { + plugin: PluginRecord; + plugins: PluginRecord[]; +}) { + const { t } = useTranslation(); + const { isMobile, isFinePointer } = useResponsiveBreakpoint(); + const pluginEntries = useMemo(() => buildPluginShortcutEntries(plugins), [plugins]); + const selectedEntries = useMemo( + () => + pluginEntries.filter( + (entry): entry is PluginShortcutEntry => + entry.source === "plugin" && entry.pluginId === plugin.id, + ), + [plugin.id, pluginEntries], + ); + const { saved, draft, setDraft, isDirty } = usePluginShortcutDraft( + `plugin-shortcuts:${plugin.id}`, + ); + + const conflictLabels = useShortcutConflictLabels(pluginEntries, draft, t); + if (selectedEntries.length === 0) return null; + + const touchSized = isMobile || !isFinePointer; + return ( + + + {t("plugins:shortcuts")} + + +

+ {t("plugins:shortcutsDescription")} +

+
+ {selectedEntries.map((entry) => ( + setDraft((current) => ({ ...current, [id]: shortcut }))} + onReset={(id) => + setDraft((current) => { + const next = { ...current }; + delete next[id]; + return next; + }) + } + onClear={(id) => + setDraft((current) => ({ + ...current, + [id]: UNBOUND_SHORTCUT, + })) + } + /> + ))} +
+

+ {t("settings:clickAShortcutToRecordA")} +

+
+
+ ); +} + +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