Merge pull request #6546 from Kilo-Org/mark/csp-connect-src-fix

fix(vscode): CSP, inline permissions, and composite visual regression tests
This commit is contained in:
Mark IJbema
2026-03-03 19:13:22 +01:00
committed by GitHub
29 changed files with 1124 additions and 23 deletions
+1
View File
@@ -4,3 +4,4 @@
# Visual regression baseline snapshots
packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
+95
View File
@@ -7,6 +7,9 @@ on:
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- ".github/workflows/visual-regression.yml"
jobs:
@@ -101,3 +104,95 @@ jobs:
name: visual-regression-results
path: packages/kilo-ui/test-results/
retention-days: 7
visual-regression-vscode:
name: Visual Regression (kilo-vscode webview)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.head_ref }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun modules
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install
- name: Cache Playwright browsers
id: playwright-cache-vscode
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-vscode-${{ hashFiles('packages/kilo-vscode/package.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache-vscode.outputs.cache-hit != 'true'
run: bunx playwright install chromium
working-directory: packages/kilo-vscode
- name: Install Playwright system deps
run: bunx playwright install-deps chromium
working-directory: packages/kilo-vscode
- name: Cache Storybook build
id: storybook-cache-vscode
uses: actions/cache@v4
with:
path: packages/kilo-vscode/storybook-static
key: storybook-vscode-${{ hashFiles('packages/kilo-vscode/webview-ui/src/**', 'packages/kilo-vscode/.storybook/**', 'packages/kilo-ui/src/**', 'packages/ui/src/**', 'packages/kilo-vscode/package.json') }}
- name: Build Storybook
if: steps.storybook-cache-vscode.outputs.cache-hit != 'true'
run: bun run build-storybook
working-directory: packages/kilo-vscode
- name: Generate baselines for new/missing stories
run: bun run test:visual:update
working-directory: packages/kilo-vscode
env:
CI: true
PLAYWRIGHT_WORKERS: "4"
- name: Commit and push new baselines (if any)
id: commit-baselines-vscode
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No new baselines — nothing to commit."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
git commit -m "chore: update kilo-vscode visual regression baselines"
git lfs push --all origin
git push
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Fail if baselines changed
if: steps.commit-baselines-vscode.outputs.changed == 'true'
run: |
echo "::error::Visual regression baselines changed. New baselines have been committed to the branch. Please pull and review."
exit 1
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-regression-vscode-results
path: packages/kilo-vscode/test-results/
retention-days: 7
+6
View File
@@ -249,6 +249,8 @@
"zod": "^3.24.2",
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@storybook/addon-docs": "10.2.10",
"@types/diff": "^6.0.0",
"@types/mocha": "^10.0.10",
"@types/node": "22.x",
@@ -263,9 +265,13 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"storybook": "10.2.10",
"storybook-solidjs-vite": "10.0.9",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "7.3.1",
"vite-plugin-solid": "2.11.10",
},
},
"packages/opencode": {
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c67c7cc21cd458c632228bdcee310615fae82c6d4c76e4d1170fa00224a9ac3
size 29215
oid sha256:1de30d219cd299acf26c9fe473c00f18cf057297db2e9b1ae44fb3116b0392f0
size 31305
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c67c7cc21cd458c632228bdcee310615fae82c6d4c76e4d1170fa00224a9ac3
size 29215
oid sha256:1de30d219cd299acf26c9fe473c00f18cf057297db2e9b1ae44fb3116b0392f0
size 31305
+3 -1
View File
@@ -1,3 +1,5 @@
bin/
out/
.vscode-test/
.vscode-test/
storybook-static/
test-results/
+27
View File
@@ -0,0 +1,27 @@
import type { StorybookConfig } from "storybook-solidjs-vite"
import { mergeConfig } from "vite"
import solidPlugin from "vite-plugin-solid"
const config: StorybookConfig = {
framework: "storybook-solidjs-vite",
stories: ["../webview-ui/src/stories/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-docs"],
refs: {},
viteFinal: async (config) => {
return mergeConfig(config, {
plugins: [solidPlugin()],
resolve: {
conditions: ["browser", "solid", "module", "import"],
},
esbuild: {
jsxImportSource: "solid-js",
jsx: "automatic",
},
worker: {
format: "es",
},
})
},
}
export default config
@@ -0,0 +1,84 @@
/** @jsxImportSource solid-js */
import type { Preview, SolidRenderer } from "storybook-solidjs-vite"
import type { DecoratorFunction } from "storybook/internal/types"
// Reference kilo-ui stories helpers directly — not exported via package.json
import { applyKiloTheme, applyVscodeTheme, clearVscodeTheme } from "../../kilo-ui/src/stories/theme-decorator"
import "../../kilo-ui/.storybook/fonts.css"
import "@kilocode/kilo-ui/styles"
import "../webview-ui/src/styles/chat.css"
const themeDecorator: DecoratorFunction<SolidRenderer> = (Story, context) => {
const themeId = (context.globals["theme"] as string) ?? "kilo-vscode"
const vscodeThemeId = (context.globals["vscodeTheme"] as string) ?? "dark-modern"
const colorScheme = (() => {
if (themeId === "kilo-vscode") return applyVscodeTheme(vscodeThemeId)
clearVscodeTheme()
return (context.globals["colorScheme"] as "light" | "dark") ?? "dark"
})()
applyKiloTheme(themeId, colorScheme)
document.body.style.background = "var(--background-base)"
document.body.style.color = "var(--text-base)"
return Story()
}
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: "fullscreen",
},
decorators: [themeDecorator],
globalTypes: {
theme: {
description: "Theme",
toolbar: {
title: "Theme",
icon: "paintbrush",
items: [
{ value: "kilo-vscode", title: "Kilo VSCode" },
{ value: "kilo", title: "Kilo" },
],
dynamicTitle: true,
},
},
colorScheme: {
description: "Color Scheme",
toolbar: {
title: "Color Scheme",
icon: "circlehollow",
items: [
{ value: "dark", title: "Dark", icon: "moon" },
{ value: "light", title: "Light", icon: "sun" },
],
dynamicTitle: true,
},
},
vscodeTheme: {
description: "VSCode Theme",
toolbar: {
title: "VSCode Theme",
icon: "browser",
items: [
{ value: "dark-modern", title: "Dark Modern (default)" },
{ value: "dark-plus", title: "Dark+" },
{ value: "light-modern", title: "Light Modern" },
{ value: "hc-black", title: "High Contrast" },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: "kilo-vscode",
colorScheme: "dark",
vscodeTheme: "dark-modern",
},
}
export default preview
@@ -0,0 +1,8 @@
{
"extends": "../webview-ui/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": ["./**/*", "../webview-ui/src/**/*"]
}
+11 -1
View File
@@ -672,9 +672,15 @@
"lint": "eslint src",
"test": "vscode-test",
"test:unit": "bun test tests/unit/",
"rebuild-sdk": "bun run --cwd ../sdk/js build"
"rebuild-sdk": "bun run --cwd ../sdk/js build",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o storybook-static",
"test:visual": "playwright test",
"test:visual:update": "playwright test --update-snapshots"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@storybook/addon-docs": "10.2.10",
"@types/diff": "^6.0.0",
"@types/mocha": "^10.0.10",
"@types/node": "22.x",
@@ -688,9 +694,13 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"storybook": "10.2.10",
"storybook-solidjs-vite": "10.0.9",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "7.3.1",
"vite-plugin-solid": "2.11.10",
"@vscode/vsce": "^3.7.1"
},
"dependencies": {
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env["CI"],
retries: process.env["CI"] ? 1 : 0,
workers: process.env["PLAYWRIGHT_WORKERS"]
? Number.parseInt(process.env["PLAYWRIGHT_WORKERS"]!, 10) || undefined
: undefined,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: "http://localhost:6007",
// VS Code sidebar is typically 350-450px wide
viewport: { width: 420, height: 720 },
reducedMotion: "reduce",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"], viewport: { width: 420, height: 720 } },
},
],
webServer: {
command:
"bunx storybook build -o ./storybook-static && bunx http-server ./storybook-static -p 6007 --silent",
url: "http://localhost:6007",
reuseExistingServer: !process.env["CI"],
timeout: 300_000,
},
timeout: 60_000,
expect: {
timeout: 10_000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
},
},
})
@@ -27,7 +27,7 @@ export function buildCspString(cspSource: string, nonce: string, port?: number):
`style-src 'unsafe-inline' ${cspSource}`,
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
`font-src ${cspSource}`,
`connect-src ${connectSrc}`,
`connect-src ${cspSource} ${connectSrc}`,
`img-src ${cspSource} data: https:`,
]
return joinCspDirectives(directives)
@@ -67,6 +67,11 @@ describe("buildCspString", () => {
expect(result).not.toContain(":*")
})
it("includes cspSource in connect-src for source map loading", () => {
const result = buildCspString(cspSource, nonce)
expect(result).toMatch(new RegExp(`connect-src\\s+${cspSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`))
})
it("joins directives with semicolons", () => {
const result = buildCspString(cspSource, nonce)
const parts = result.split(";")
@@ -0,0 +1,77 @@
import { test, expect, type Page } from "@playwright/test"
import { platform } from "node:os"
const IS_DARWIN = platform() === "darwin"
// Screenshot baselines are captured on Linux CI — font rendering and anti-aliasing
// differ on macOS, which causes false-positive diffs. Skip the entire suite there.
if (IS_DARWIN) {
console.warn("Visual regression tests must be run on CI, skipping on local macOS.")
test.skip()
}
type Story = {
id: string
title: string
name: string
}
type StoriesIndex = {
stories?: Record<string, Story>
entries?: Record<string, Story>
}
const STORYBOOK_URL = "http://localhost:6007"
// Fetched once per worker process — cheap HTTP call to the already-running Storybook
async function fetchStories(): Promise<Story[]> {
const res = await fetch(`${STORYBOOK_URL}/index.json`).catch(() => fetch(`${STORYBOOK_URL}/stories.json`))
if (!res.ok) throw new Error(`Storybook index fetch failed: ${res.status} ${res.statusText}`)
const data = (await res.json()) as StoriesIndex
const map = data.entries ?? data.stories ?? {}
return Object.values(map).filter((s) => s.id && !s.id.endsWith("--docs"))
}
async function disableAnimations(page: Page) {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
})
}
// Stories to skip from visual regression (add IDs here if needed)
const SKIP = new Set<string>([])
// Use test.describe + beforeAll to avoid top-level await which breaks
// on Node.js versions with strict ESM/CJS boundaries.
test.describe("Visual Regression", () => {
let stories: Story[] = []
test.beforeAll(async () => {
if (IS_DARWIN) return
stories = (await fetchStories()).filter((s) => !SKIP.has(s.id))
})
test("all stories", async ({ page }) => {
if (IS_DARWIN) return
for (const story of stories) {
// Use kilo-vscode theme by default (matched to the preview initialGlobals)
await page.goto(
`/iframe.html?id=${story.id}&viewMode=story&globals=colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern`,
{ waitUntil: "load" },
)
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
const [component, variant] = story.id.split("--")
const root = page.locator("#storybook-root")
await expect(root).toHaveScreenshot([component!, `${variant}.png`])
}
})
})
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04332222bf7bf3680954f967414f93e9e3d03747961fada79cd32d4a5e2a1431
size 10279
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee4898a628110edf572fa27433bbb905d260225be0dd2154ccdfd4e4a2ea4c1e
size 4415
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b6d383088e69f94aeed3e9291092d1278f8346b2d50889bd83ecc72db5d13b7
size 2846
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1c5266bca40764f48d0cdefa3398083708ed7deb354acf8a5a296f9f9e0b2a6
size 9005
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c13ea96b21def4f7359426d9d8900bab511938d4f71bc6635daaba44a2bbd133
size 25078
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd8a53356f0aa0df5c07246296dc20273f5380a012cd579885b1974e3aeb99e1
size 8496
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa7bddaa15cf80565ef5b639bfe2de8d2f373019da4b72210c8ea82aaf47913f
size 10384
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64ff66248b1c07209757eb6e27ccac4967650b144317ee21076ba82c129bc17b
size 8846
@@ -3,12 +3,19 @@
* Renders all parts of an assistant message as a flat list — no context grouping.
* Unlike the upstream AssistantParts, this renders each read/glob/grep/list tool
* individually for maximum verbosity in the VS Code sidebar context.
*
* Permissions and questions with a tool context are rendered inline with their
* tool call rather than in the bottom dock.
*/
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Part, PART_MAPPING } from "@kilocode/kilo-ui/message-part"
import { Button } from "@kilocode/kilo-ui/button"
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, Message as SDKMessage } from "@kilocode/sdk/v2"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { QuestionDock } from "./QuestionDock"
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
@@ -33,6 +40,8 @@ interface AssistantMessageProps {
export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const data = useData()
const session = useSession()
const language = useLanguage()
const parts = createMemo(() => {
const stored = data.store.part?.[props.message.id]
@@ -40,18 +49,76 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
return (stored as SDKPart[]).filter(isRenderable)
})
const id = () => session.currentSessionID()
const permissions = () => session.permissions().filter((p) => p.sessionID === id() && p.tool)
const questions = () => session.questions().filter((q) => q.sessionID === id() && q.tool)
const permissionForPart = (part: SDKPart) => {
if (part.type !== "tool") return undefined
const callID = (part as SDKPart & { callID: string }).callID
return permissions().find((p) => p.tool!.callID === callID && p.tool!.messageID === props.message.id)
}
// Questions linked to this message (rendered after the last part)
const questionForMessage = () =>
questions().find((q) => q.tool!.messageID === props.message.id)
const [responding, setResponding] = createSignal(false)
const decide = (permissionId: string, response: "once" | "always" | "reject") => {
if (responding()) return
setResponding(true)
session.respondToPermission(permissionId, response)
setResponding(false)
}
return (
<For each={parts()}>
{(part) => (
<Show when={PART_MAPPING[part.type]}>
<Part
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
</Show>
)}
</For>
<>
<For each={parts()}>
{(part) => {
const perm = () => permissionForPart(part)
return (
<Show when={PART_MAPPING[part.type]}>
<div data-component="tool-part-wrapper" data-permission={!!perm()}>
<Part
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
<Show when={perm()} keyed>
{(p) => (
<div data-component="permission-prompt">
<Show when={p.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={p.patterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
</div>
</Show>
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => decide(p.id, "reject")} disabled={responding()}>
{language.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => decide(p.id, "always")} disabled={responding()}>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => decide(p.id, "once")} disabled={responding()}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
</div>
)}
</Show>
</div>
</Show>
)
}}
</For>
<Show when={questionForMessage()} keyed>
{(req) => <QuestionDock request={req} />}
</Show>
</>
)
}
@@ -26,7 +26,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const sessionQuestions = () => session.questions().filter((q) => q.sessionID === id())
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
const questionRequest = () => sessionQuestions()[0]
const questionRequest = () => sessionQuestions().find((q) => !q.tool)
const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
@@ -193,7 +193,7 @@ interface LanguageContextValue {
t: (key: string, params?: UiI18nParams) => string
}
const LanguageContext = createContext<LanguageContextValue>()
export const LanguageContext = createContext<LanguageContextValue>()
export function useLanguage() {
const ctx = useContext(LanguageContext)
@@ -138,7 +138,7 @@ interface SessionContextValue {
selectCloudSession: (cloudSessionId: string) => void
}
const SessionContext = createContext<SessionContextValue>()
export const SessionContext = createContext<SessionContextValue>()
export const SessionProvider: ParentComponent = (props) => {
const vscode = useVSCode()
@@ -0,0 +1,165 @@
/** @jsxImportSource solid-js */
/**
* StoryProviders — wraps composite stories with all required contexts.
*
* Instead of instantiating the full VSCodeProvider → ServerProvider → SessionProvider
* chain (which requires a real extension host / SSE connection), we provide mock
* context values directly.
*/
import { createSignal, type ParentComponent } from "solid-js"
import { DataProvider } from "@kilocode/kilo-ui/context/data"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { I18nProvider } from "@kilocode/kilo-ui/context"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Code } from "@kilocode/kilo-ui/code"
import { SessionContext } from "../context/session"
import { LanguageContext } from "../context/language"
import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en"
import { dict as appEn } from "../i18n/en"
import { dict as kiloEn } from "@kilocode/kilo-i18n/en"
import type { PermissionRequest, QuestionRequest } from "../types/messages"
// Merged English dictionary (same merge order as the real LanguageProvider)
const dict: Record<string, string> = { ...appEn, ...uiEn, ...kiloEn }
function t(key: string) {
return dict[key] ?? key
}
// ---------------------------------------------------------------------------
// Default mock data (empty session)
// ---------------------------------------------------------------------------
export const defaultMockData = {
session: [],
session_status: {},
session_diff: {},
message: {} as Record<string, any[]>,
part: {} as Record<string, any[]>,
permission: {} as Record<string, any[]>,
question: {},
provider: { all: [], connected: false, default: {} },
}
// ---------------------------------------------------------------------------
// Mock SessionContext value — only the subset used by components
// ---------------------------------------------------------------------------
function noop() {}
export function mockSessionValue(overrides?: {
id?: string
permissions?: PermissionRequest[]
questions?: QuestionRequest[]
status?: string
}) {
const id = overrides?.id ?? "story-session-001"
const permissions = overrides?.permissions ?? []
const qs = overrides?.questions ?? []
const status = (overrides?.status ?? "idle") as "idle" | "busy"
return {
currentSessionID: () => id,
currentSession: () => ({ id, title: "Story session", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
setCurrentSessionID: noop,
sessions: () => [],
status: () => status,
statusInfo: () => ({ type: status }),
statusText: () => (status === "idle" ? undefined : "Thinking…"),
busySince: () => (status === "busy" ? Date.now() - 2000 : undefined),
loading: () => false,
messages: () => [],
userMessages: () => [],
allMessages: () => ({}),
allParts: () => ({}),
allStatusMap: () => ({}),
getParts: () => [],
todos: () => [],
permissions: () => permissions,
questions: () => qs,
questionErrors: () => new Set<string>(),
selected: () => ({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }),
selectModel: noop,
totalCost: () => 0,
contextUsage: () => undefined,
agents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }],
selectedAgent: () => "code",
selectAgent: noop,
getSessionAgent: () => "code",
getSessionModel: () => ({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }),
setSessionModel: noop,
setSessionAgent: noop,
variantList: () => [],
currentVariant: () => undefined,
selectVariant: noop,
sendMessage: noop,
abort: noop,
compact: noop,
respondToPermission: noop,
replyToQuestion: noop,
rejectQuestion: noop,
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
selectSession: noop,
deleteSession: noop,
renameSession: noop,
syncSession: noop,
cloudPreviewId: () => null,
selectCloudSession: noop,
}
}
// ---------------------------------------------------------------------------
// StoryProviders component
// ---------------------------------------------------------------------------
interface StoryProvidersProps {
data?: any
permissions?: PermissionRequest[]
questions?: QuestionRequest[]
status?: string
sessionID?: string
}
export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
const data = () => props.data ?? defaultMockData
const session = mockSessionValue({
id: props.sessionID,
permissions: props.permissions,
questions: props.questions,
status: props.status,
})
const [locale] = createSignal<"en">("en")
return (
<DialogProvider>
<LanguageContext.Provider
value={{
locale,
setLocale: noop,
userOverride: () => "" as any,
t,
}}
>
<I18nProvider value={{ locale: () => "en", t }}>
<SessionContext.Provider value={session as any}>
<DataProvider data={data()} directory="/project/">
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<MarkedProvider>
<div style={{ padding: "12px" }}>{props.children}</div>
</MarkedProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</DataProvider>
</SessionContext.Provider>
</I18nProvider>
</LanguageContext.Provider>
</DialogProvider>
)
}
@@ -0,0 +1,463 @@
/** @jsxImportSource solid-js */
/**
* Composite visual regression stories for the kilo-vscode webview.
*
* These test the *composed* UI — how kilo-ui components look together
* in the extension webview context with extension-specific styling,
* inline permission prompts, and tool card overrides.
*/
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { For, Show } from "solid-js"
import { Part } from "@kilocode/kilo-ui/message-part"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Button } from "@kilocode/kilo-ui/button"
import type {
AssistantMessage as SDKAssistantMessage,
TextPart,
ToolPart,
Message as SDKMessage,
} from "@kilocode/sdk/v2"
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
import { AssistantMessage } from "../components/chat/AssistantMessage"
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
import { SessionContext } from "../context/session"
import type { PermissionRequest, QuestionRequest } from "../types/messages"
// Register VS Code tool overrides (bash expanded by default, etc.)
registerVscodeToolOverrides()
// ---------------------------------------------------------------------------
// Shared constants
// ---------------------------------------------------------------------------
const SESSION_ID = "story-session-001"
const ASST_MSG_ID = "asst-msg-001"
const now = Date.now()
const baseAssistantMessage: SDKAssistantMessage = {
id: ASST_MSG_ID,
sessionID: SESSION_ID,
role: "assistant",
parentID: "user-msg-001",
time: { created: now - 9000, completed: now - 5000 },
modelID: "claude-sonnet-4-20250514",
providerID: "anthropic",
mode: "default",
agent: "default",
path: { cwd: "/project", root: "/project" },
cost: 0.0023,
tokens: { total: 512, input: 256, output: 256, reasoning: 0, cache: { read: 0, write: 0 } },
}
// ---------------------------------------------------------------------------
// Tool parts
// ---------------------------------------------------------------------------
const globPending: ToolPart = {
id: "part-glob-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-glob-001",
tool: "glob",
state: {
status: "pending",
input: { pattern: "**/*.md", path: "." },
title: "Searching for files",
metadata: {},
time: { start: now - 3000 },
},
}
const readCompleted: ToolPart = {
id: "part-read-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-read-001",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/main.tsx" },
output: 'import { render } from "solid-js/web"\nrender(() => <App />, document.getElementById("root")!)',
title: "Read file",
metadata: {},
time: { start: now - 8000, end: now - 7500 },
},
}
const grepCompleted: ToolPart = {
id: "part-grep-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-grep-001",
tool: "grep",
state: {
status: "completed",
input: { pattern: "TODO", path: "src/" },
output: "src/main.tsx:12: // TODO: add error boundary\nsrc/utils.ts:5: // TODO: refactor",
title: "Search",
metadata: {},
time: { start: now - 7000, end: now - 6500 },
},
}
const globCompleted: ToolPart = {
id: "part-glob-002",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-glob-002",
tool: "glob",
state: {
status: "completed",
input: { pattern: "src/**/*.ts", path: "." },
output: "src/main.ts\nsrc/utils.ts\nsrc/types.ts",
title: "Found 3 files",
metadata: {},
time: { start: now - 6000, end: now - 5800 },
},
}
const lsCompleted: ToolPart = {
id: "part-ls-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-ls-001",
tool: "ls",
state: {
status: "completed",
input: { path: "." },
output: "src/\npackage.json\ntsconfig.json\nREADME.md",
title: "List directory",
metadata: {},
time: { start: now - 5500, end: now - 5400 },
},
}
const bashPending: ToolPart = {
id: "part-bash-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-bash-001",
tool: "bash",
state: {
status: "pending",
input: { description: "Run tests", command: "bun test" },
title: "Execute command",
metadata: {},
time: { start: now - 2000 },
},
}
const textPart: TextPart = {
id: "part-text-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "text",
text: "I found the relevant files and will now update them.",
}
// ---------------------------------------------------------------------------
// Permission fixtures
// ---------------------------------------------------------------------------
const globPermission: PermissionRequest = {
id: "perm-glob-001",
sessionID: SESSION_ID,
toolName: "glob",
patterns: ["**/*.md"],
args: { pattern: "**/*.md" },
tool: { messageID: ASST_MSG_ID, callID: "call-glob-001" },
}
const bashPermission: PermissionRequest = {
id: "perm-bash-001",
sessionID: SESSION_ID,
toolName: "bash",
patterns: ["bun test"],
args: { command: "bun test" },
tool: { messageID: ASST_MSG_ID, callID: "call-bash-001" },
}
const dockPermission: PermissionRequest = {
id: "perm-dock-001",
sessionID: SESSION_ID,
toolName: "write",
patterns: ["src/main.tsx", "src/utils.ts"],
args: {},
// No `tool` field — this is a non-tool (dock) permission
}
// ---------------------------------------------------------------------------
// Question fixtures
// ---------------------------------------------------------------------------
const questionRequest: QuestionRequest = {
id: "question-001",
sessionID: SESSION_ID,
questions: [
{
question: "Which testing framework should I use for this project?",
header: "Choose a framework",
options: [
{ label: "Vitest", description: "Fast, Vite-native unit testing" },
{ label: "Jest", description: "Widely adopted, rich ecosystem" },
{ label: "Playwright", description: "End-to-end browser testing" },
{ label: "Bun test", description: "Built-in, zero config" },
],
},
],
tool: { messageID: ASST_MSG_ID, callID: "call-question-001" },
}
const questionToolPart: ToolPart = {
id: "part-question-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-question-001",
tool: "question",
state: {
status: "running",
input: { question: "Which testing framework?", options: [] },
title: "Asking question",
metadata: {},
time: { start: now - 1000 },
},
}
// ---------------------------------------------------------------------------
// Data helpers
// ---------------------------------------------------------------------------
function dataWith(parts: any[], permissions?: PermissionRequest[]) {
return {
...defaultMockData,
message: {
[SESSION_ID]: [baseAssistantMessage],
},
part: {
[ASST_MSG_ID]: parts,
},
permission: permissions
? { [SESSION_ID]: permissions }
: {},
}
}
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta = {
title: "Composite/Webview",
parameters: { layout: "padded" },
}
export default meta
type Story = StoryObj
// ---------------------------------------------------------------------------
// 1. Tool with inline permission (glob)
// ---------------------------------------------------------------------------
export const GlobWithPermission: Story = {
name: "Glob + Inline Permission",
render: () => {
const perms = [globPermission]
const data = dataWith([globPending], perms)
return (
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 2. Tool with inline permission (bash)
// ---------------------------------------------------------------------------
export const BashWithPermission: Story = {
name: "Bash + Inline Permission",
render: () => {
const perms = [bashPermission]
const data = dataWith([bashPending], perms)
return (
<StoryProviders data={data} permissions={perms} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 3. Permission dock (non-tool bottom prompt)
// ---------------------------------------------------------------------------
export const PermissionDock: Story = {
name: "Permission Dock",
render: () => {
const perm = dockPermission
return (
<StoryProviders sessionID={SESSION_ID}>
<div data-component="tool-part-wrapper" data-permission="true">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: "Permission required",
subtitle: perm.toolName,
}}
>
<Show when={perm.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small">
Deny
</Button>
<Button variant="secondary" size="small">
Always Allow
</Button>
<Button variant="primary" size="small">
Allow Once
</Button>
</div>
<p data-slot="permission-hint">Approval applies only to the current session</p>
</div>
</div>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 4. Tool cards — read, glob, grep, ls
// ---------------------------------------------------------------------------
export const ToolCards: Story = {
name: "Tool Cards",
render: () => {
const data = dataWith([readCompleted, globCompleted, grepCompleted, lsCompleted])
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 5. Chat idle — prompt input placeholder
// ---------------------------------------------------------------------------
export const ChatIdle: Story = {
name: "Chat Idle",
render: () => (
<StoryProviders sessionID={SESSION_ID} status="idle">
<div class="chat-view" style={{ width: "380px" }}>
<div class="chat-input">
<div
style={{
padding: "8px 12px",
border: "1px solid var(--border-base)",
"border-radius": "8px",
color: "var(--text-dimmed)",
"font-size": "13px",
background: "var(--background-input)",
}}
>
Ask anything… (⌘ Enter)
</div>
</div>
</div>
</StoryProviders>
),
}
// ---------------------------------------------------------------------------
// 6. Chat busy — working indicator, no prompt
// ---------------------------------------------------------------------------
export const ChatBusy: Story = {
name: "Chat Busy",
render: () => {
const data = dataWith([textPart])
return (
<StoryProviders data={data} sessionID={SESSION_ID} status="busy">
<div class="chat-view" style={{ width: "380px" }}>
<div class="vscode-session-turn-assistant">
<AssistantMessage message={baseAssistantMessage} />
</div>
<div
style={{
padding: "8px 12px",
display: "flex",
"align-items": "center",
gap: "8px",
color: "var(--text-dimmed)",
"font-size": "13px",
}}
>
<span
style={{
width: "8px",
height: "8px",
"border-radius": "50%",
background: "var(--accent-base)",
animation: "pulse 1.5s infinite",
}}
/>
Thinking…
</div>
</div>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 7. Multiple tool calls in one assistant message
// ---------------------------------------------------------------------------
export const MultipleToolCalls: Story = {
name: "Multiple Tool Calls",
render: () => {
const data = dataWith([readCompleted, globCompleted, textPart])
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 8. Inline question (tool-linked question rendered in message flow)
// ---------------------------------------------------------------------------
export const InlineQuestion: Story = {
name: "Inline Question",
render: () => {
const qs = [questionRequest]
const data = dataWith([textPart, questionToolPart])
return (
<StoryProviders data={data} questions={qs} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
@@ -696,6 +696,33 @@
word-break: break-all;
}
/* Permission Prompt (inline with tool cards) */
[data-component="tool-part-wrapper"][data-permission="true"] {
/* When wrapping a Part + permission, the outer wrapper provides the card border.
Remove the inner Part's tool-part-wrapper border so we don't get double borders. */
> [data-component="tool-part-wrapper"] {
border: none;
border-radius: 0;
}
}
[data-component="permission-prompt"] {
border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border));
padding: 8px 12px;
}
[data-component="permission-prompt"] [data-slot="permission-actions"] {
display: flex;
align-items: center;
gap: 6px;
}
[data-component="permission-prompt"] [data-slot="permission-hint"] {
font-size: 11px;
color: var(--text-weak, var(--vscode-descriptionForeground));
margin: 4px 0 0;
}
/* Dialog Confirmation */
.dialog-confirm-body {
display: flex;