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] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Imanol Maiztegui
2026-03-18 10:26:34 +01:00
committed by GitHub
co-authored by github-actions[bot]
parent 3f5d0893bb
commit b136dbe9cc
9 changed files with 188 additions and 8 deletions
@@ -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
// ---------------------------------------------------------------------------
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc81b3e1762b6a8f2b1b9708ca548042ae019cd4fb0d34ccbe53b3ee204047e6
size 18145
@@ -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({})
})
})
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1cf3791372e3e69fd759f2596e72211c982e8694990103c178f1aed0c59229f1
size 16139
@@ -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<Record<number, RuleDecision>>({})
// 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<Record<number, RuleDecision>>(loadState)
const [expanded, setExpanded] = createSignal(rulesExpandedPreference)
const hasRules = () => rules().length > 0
@@ -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<number, RuleDecision> {
const result: Record<number, RuleDecision> = {}
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
}
@@ -45,7 +45,7 @@ function stripNulls(obj: Config): Config {
return result as Config
}
const ConfigContext = createContext<ConfigContextValue>()
export const ConfigContext = createContext<ConfigContextValue>()
export const ConfigProvider: ParentComponent = (props) => {
const vscode = useVSCode()
@@ -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<string, string> = { ...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 <ConfigContext.Provider value={value}>{props.children}</ConfigContext.Provider>
}
return <ConfigProvider>{props.children}</ConfigProvider>
}
export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
const data = () => props.data ?? defaultMockData
const session = mockSessionValue({
@@ -206,7 +221,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
return (
<VSCodeProvider>
<ServerProvider>
<ConfigProvider>
<ConfigWrapper config={props.config}>
<MockProviderProvider>
<DialogProvider>
<LanguageContext.Provider
@@ -239,7 +254,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
</LanguageContext.Provider>
</DialogProvider>
</MockProviderProvider>
</ConfigProvider>
</ConfigWrapper>
</ServerProvider>
</VSCodeProvider>
)
@@ -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 (
<StoryProviders
permissions={perms}
sessionID={SESSION_ID}
status="busy"
noPadding
config={{
permission: {
bash: {
"*": "ask",
"npm *": "allow",
"npm install": "allow",
"git *": "deny",
},
},
}}
>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "400px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 17. MCP tool cards — collapsed
// ---------------------------------------------------------------------------