From b136dbe9cc0403daa62f89a715eb77f8e90aa5ba Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Wed, 18 Mar 2026 10:26:34 +0100 Subject: [PATCH] Pre-populate permission dock toggles from config (#7217) * feat(vscode): pre-populate permission dock toggles from saved config * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] --- .../tests/permission-dock-dropdown.spec.ts | 18 +++++ ...nfig-preloaded-expanded-chromium-linux.png | 3 + .../tests/unit/permission-dock-utils.test.ts | 65 +++++++++++++++++++ ...n-dock-config-preloaded-chromium-linux.png | 3 + .../src/components/chat/PermissionDock.tsx | 11 +++- .../components/chat/permission-dock-utils.ts | 19 ++++++ .../webview-ui/src/context/config.tsx | 2 +- .../webview-ui/src/stories/StoryProviders.tsx | 23 +++++-- .../src/stories/composite.stories.tsx | 52 +++++++++++++++ 9 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png create mode 100644 packages/kilo-vscode/tests/unit/permission-dock-utils.test.ts create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts index 71978fdc544..1982129331d 100644 --- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts @@ -259,6 +259,24 @@ test.describe("Permission Dock Dropdown — many rules", () => { }) }) +// --------------------------------------------------------------------------- +// Config pre-populated — rules show saved allow/deny state from config +// --------------------------------------------------------------------------- + +test.describe("Permission Dock Dropdown — config pre-populated", () => { + const STORY_ID = "composite-webview--permission-dock-config-preloaded" + + test("rules expanded — pre-populated from config (mixed allow/deny/pending)", async ({ page }) => { + await page.goto(storyUrl(STORY_ID), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + await openDropdown(page) + + const root = page.locator("#storybook-root") + await expect(root).toHaveScreenshot(["permission-dock-dropdown", "config-preloaded-expanded.png"]) + }) +}) + // --------------------------------------------------------------------------- // Subagent permission — shows "(subagent)" in subtitle // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png new file mode 100644 index 00000000000..341c97f5262 --- /dev/null +++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc81b3e1762b6a8f2b1b9708ca548042ae019cd4fb0d34ccbe53b3ee204047e6 +size 18145 diff --git a/packages/kilo-vscode/tests/unit/permission-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/permission-dock-utils.test.ts new file mode 100644 index 00000000000..c16c399e659 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/permission-dock-utils.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "bun:test" +import { savedRuleStates } from "../../webview-ui/src/components/chat/permission-dock-utils" + +describe("savedRuleStates", () => { + it("returns empty map when rule is undefined", () => { + expect(savedRuleStates(["npm *", "git *"], undefined)).toEqual({}) + }) + + it("returns empty map when rules array is empty", () => { + expect(savedRuleStates([], { "npm *": "allow" })).toEqual({}) + }) + + it("populates approved entries from config", () => { + const result = savedRuleStates(["npm *", "git *", "rm *"], { "npm *": "allow", "rm *": "allow" }) + expect(result).toEqual({ 0: "approved", 2: "approved" }) + }) + + it("populates denied entries from config", () => { + const result = savedRuleStates(["npm *", "rm *"], { "rm *": "deny" }) + expect(result).toEqual({ 1: "denied" }) + }) + + it("populates mixed approved and denied", () => { + const result = savedRuleStates(["npm *", "git *", "rm *"], { + "npm *": "allow", + "git *": "deny", + }) + expect(result).toEqual({ 0: "approved", 1: "denied" }) + }) + + it("skips ask entries (they stay pending)", () => { + const result = savedRuleStates(["npm *", "git *"], { "npm *": "ask", "git *": "allow" }) + expect(result).toEqual({ 1: "approved" }) + }) + + it("handles scalar rule with wildcard in rules array", () => { + const result = savedRuleStates(["*"], "allow") + expect(result).toEqual({ 0: "approved" }) + }) + + it("handles scalar rule with non-wildcard patterns (all pending)", () => { + const result = savedRuleStates(["npm *", "git *"], "allow") + expect(result).toEqual({}) + }) + + it("returns empty map for scalar ask with wildcard", () => { + const result = savedRuleStates(["*"], "ask") + expect(result).toEqual({}) + }) + + it("returns denied for scalar deny with wildcard", () => { + const result = savedRuleStates(["*"], "deny") + expect(result).toEqual({ 0: "denied" }) + }) + + it("returns pending for patterns not in config object", () => { + const result = savedRuleStates(["npm *", "git *"], { "npm *": "allow" }) + expect(result).toEqual({ 0: "approved" }) + }) + + it("returns empty map for empty config object", () => { + const result = savedRuleStates(["npm *"], {}) + expect(result).toEqual({}) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png new file mode 100644 index 00000000000..f333bde8d1c --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cf3791372e3e69fd759f2596e72211c982e8694990103c178f1aed0c59229f1 +size 16139 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 282ce8cddf6..ff92f454fbb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -17,10 +17,10 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" +import { useConfig } from "../../context/config" +import { savedRuleStates, type RuleDecision } from "./permission-dock-utils" import type { PermissionRequest } from "../../types/messages" -type RuleDecision = "approved" | "denied" | "pending" - let rulesExpandedPreference = false export const PermissionDock: Component<{ @@ -30,6 +30,7 @@ export const PermissionDock: Component<{ }> = (props) => { const session = useSession() const language = useLanguage() + const { config } = useConfig() const fromChild = () => props.request.sessionID !== session.currentSessionID() // Bash sends fine-grained rules via metadata.rules; other tools use the always array. @@ -42,7 +43,11 @@ export const PermissionDock: Component<{ return typeof cmd === "string" ? cmd : undefined } - const [decisions, setDecisions] = createSignal>({}) + // Pre-populate toggle states from existing config rules so previously + // approved/denied patterns show their saved state immediately. + const saved = config().permission?.[props.request.toolName] + const loadState = savedRuleStates(rules(), saved) + const [decisions, setDecisions] = createSignal>(loadState) const [expanded, setExpanded] = createSignal(rulesExpandedPreference) const hasRules = () => rules().length > 0 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts new file mode 100644 index 00000000000..9aa3e6eff65 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts @@ -0,0 +1,19 @@ +import type { PermissionRule } from "../../types/messages" + +export type RuleDecision = "approved" | "denied" | "pending" + +/** + * Check which rules are already saved in the user's config and return + * their initial toggle states (approved/denied). Rules not found in + * the config are omitted (they default to "pending"). + */ +export function savedRuleStates(rules: string[], saved: PermissionRule | undefined): Record { + const result: Record = {} + for (let i = 0; i < rules.length; i++) { + const pattern = rules[i] + const action = typeof saved === "string" ? (pattern === "*" ? saved : undefined) : saved?.[pattern] + if (action === "allow") result[i] = "approved" + if (action === "deny") result[i] = "denied" + } + return result +} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 12ca11dc282..bb2a93bcf65 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -45,7 +45,7 @@ function stripNulls(obj: Config): Config { return result as Config } -const ConfigContext = createContext() +export const ConfigContext = createContext() export const ConfigProvider: ParentComponent = (props) => { const vscode = useVSCode() diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 94d06670053..7e8dd7b4dfe 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -14,7 +14,7 @@ import { VSCodeProvider } from "../context/vscode" import { ServerProvider } from "../context/server" import { ProviderContext } from "../context/provider" import { flattenModels, findModel as _findModel } from "../context/provider-utils" -import { ConfigProvider } from "../context/config" +import { ConfigProvider, ConfigContext } from "../context/config" import { DataProvider } from "@kilocode/kilo-ui/context/data" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -32,7 +32,7 @@ import { dict as appEn } from "../i18n/en" import { dict as amEn } from "../../agent-manager/i18n/en" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" import { resolveTemplate } from "../context/language-utils" -import type { PermissionRequest, QuestionRequest } from "../types/messages" +import type { Config, PermissionRequest, QuestionRequest } from "../types/messages" // Merged English dictionary (same merge order as the real LanguageProvider) const dict: Record = { ...appEn, ...amEn, ...uiEn, ...kiloEn } @@ -189,10 +189,25 @@ interface StoryProvidersProps { questions?: QuestionRequest[] status?: string sessionID?: string + /** When provided, injects a mock ConfigContext with this config instead of the real ConfigProvider. */ + config?: Config /** When true, renders children without the default 12px padding wrapper */ noPadding?: boolean } +/** Wraps children with either a mock ConfigContext (when config prop is given) or the real ConfigProvider. */ +const ConfigWrapper: ParentComponent<{ config?: Config }> = (props) => { + if (props.config) { + const value = { + config: () => props.config!, + loading: () => false, + updateConfig: noop, + } + return {props.children} + } + return {props.children} +} + export const StoryProviders: ParentComponent = (props) => { const data = () => props.data ?? defaultMockData const session = mockSessionValue({ @@ -206,7 +221,7 @@ export const StoryProviders: ParentComponent = (props) => { return ( - + = (props) => { - + ) diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 72070bf8758..0c6a7082733 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -782,6 +782,58 @@ export const PermissionDockSubagent: Story = { }, } +// --------------------------------------------------------------------------- +// 17. Permission dock — bash with pre-populated config rules +// --------------------------------------------------------------------------- + +const bashConfigPermission: PermissionRequest = { + id: "perm-bash-config-001", + sessionID: SESSION_ID, + toolName: "bash", + patterns: ["npm install"], + always: ["npm install *"], + args: { + command: "npm install", + rules: ["npm *", "npm install", "npm run *", "npm test", "git *", "npx *"], + }, + tool: { messageID: ASST_MSG_ID, callID: "call-bash-config-001" }, +} + +export const PermissionDockConfigPreloaded: Story = { + name: "Permission Dock — config pre-populated", + render: () => { + const perms = [bashConfigPermission] + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }), + messages: () => [{ id: "msg-001" }] as any[], + } + return ( + + +
+ +
+
+
+ ) + }, +} + // --------------------------------------------------------------------------- // 17. MCP tool cards — collapsed // ---------------------------------------------------------------------------