diff --git a/.gitattributes b/.gitattributes index 51298ca8a81..453ae820fe8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index d764fd30543..4d23526c065 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -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 diff --git a/bun.lock b/bun.lock index d6cbe107013..c4f5b241691 100644 --- a/bun.lock +++ b/bun.lock @@ -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": { diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png index f8d8fc65ab0..3ec89b92089 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c67c7cc21cd458c632228bdcee310615fae82c6d4c76e4d1170fa00224a9ac3 -size 29215 +oid sha256:1de30d219cd299acf26c9fe473c00f18cf057297db2e9b1ae44fb3116b0392f0 +size 31305 diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png index f8d8fc65ab0..3ec89b92089 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png +++ b/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c67c7cc21cd458c632228bdcee310615fae82c6d4c76e4d1170fa00224a9ac3 -size 29215 +oid sha256:1de30d219cd299acf26c9fe473c00f18cf057297db2e9b1ae44fb3116b0392f0 +size 31305 diff --git a/packages/kilo-vscode/.gitignore b/packages/kilo-vscode/.gitignore index 07a0b88ee57..99ae09f5426 100644 --- a/packages/kilo-vscode/.gitignore +++ b/packages/kilo-vscode/.gitignore @@ -1,3 +1,5 @@ bin/ out/ -.vscode-test/ \ No newline at end of file +.vscode-test/ +storybook-static/ +test-results/ \ No newline at end of file diff --git a/packages/kilo-vscode/.storybook/main.ts b/packages/kilo-vscode/.storybook/main.ts new file mode 100644 index 00000000000..98be6d87566 --- /dev/null +++ b/packages/kilo-vscode/.storybook/main.ts @@ -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 diff --git a/packages/kilo-vscode/.storybook/preview.tsx b/packages/kilo-vscode/.storybook/preview.tsx new file mode 100644 index 00000000000..f69d799def4 --- /dev/null +++ b/packages/kilo-vscode/.storybook/preview.tsx @@ -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 = (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 diff --git a/packages/kilo-vscode/.storybook/tsconfig.json b/packages/kilo-vscode/.storybook/tsconfig.json new file mode 100644 index 00000000000..4c9af3a4816 --- /dev/null +++ b/packages/kilo-vscode/.storybook/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../webview-ui/tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": ["./**/*", "../webview-ui/src/**/*"] +} diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index ea93e0ad636..ad7079c060c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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": { diff --git a/packages/kilo-vscode/playwright.config.ts b/packages/kilo-vscode/playwright.config.ts new file mode 100644 index 00000000000..30680574fc8 --- /dev/null +++ b/packages/kilo-vscode/playwright.config.ts @@ -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, + }, + }, +}) diff --git a/packages/kilo-vscode/src/webview-html-utils.ts b/packages/kilo-vscode/src/webview-html-utils.ts index 629320dfa06..2acfb9b7f73 100644 --- a/packages/kilo-vscode/src/webview-html-utils.ts +++ b/packages/kilo-vscode/src/webview-html-utils.ts @@ -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) diff --git a/packages/kilo-vscode/tests/unit/webview-html.test.ts b/packages/kilo-vscode/tests/unit/webview-html.test.ts index 2b930f163fb..465588e7cde 100644 --- a/packages/kilo-vscode/tests/unit/webview-html.test.ts +++ b/packages/kilo-vscode/tests/unit/webview-html.test.ts @@ -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(";") diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts new file mode 100644 index 00000000000..36e5f071725 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -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 + entries?: Record +} + +const STORYBOOK_URL = "http://localhost:6007" + +// Fetched once per worker process — cheap HTTP call to the already-running Storybook +async function fetchStories(): Promise { + 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([]) + +// 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`]) + } + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png new file mode 100644 index 00000000000..44ab459e0a2 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04332222bf7bf3680954f967414f93e9e3d03747961fada79cd32d4a5e2a1431 +size 10279 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png new file mode 100644 index 00000000000..116b32df4ce --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-busy-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee4898a628110edf572fa27433bbb905d260225be0dd2154ccdfd4e4a2ea4c1e +size 4415 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-idle-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-idle-chromium-linux.png new file mode 100644 index 00000000000..bb29a832f34 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-idle-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b6d383088e69f94aeed3e9291092d1278f8346b2d50889bd83ecc72db5d13b7 +size 2846 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png new file mode 100644 index 00000000000..8b5892592b8 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1c5266bca40764f48d0cdefa3398083708ed7deb354acf8a5a296f9f9e0b2a6 +size 9005 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/inline-question-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/inline-question-chromium-linux.png new file mode 100644 index 00000000000..b0d7e63b64e --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/inline-question-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c13ea96b21def4f7359426d9d8900bab511938d4f71bc6635daaba44a2bbd133 +size 25078 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png new file mode 100644 index 00000000000..60ef12e51a0 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd8a53356f0aa0df5c07246296dc20273f5380a012cd579885b1974e3aeb99e1 +size 8496 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-chromium-linux.png new file mode 100644 index 00000000000..ed03cefc81b --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa7bddaa15cf80565ef5b639bfe2de8d2f373019da4b72210c8ea82aaf47913f +size 10384 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png new file mode 100644 index 00000000000..5112b15c21e --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64ff66248b1c07209757eb6e27ccac4967650b144317ee21076ba82c129bc17b +size 8846 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 4242dc89d37..9abcc24eb37 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -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 = (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 = (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 ( - - {(part) => ( - - - - )} - + <> + + {(part) => { + const perm = () => permissionForPart(part) + return ( + +
+ + + {(p) => ( +
+ 0}> +
+ + {(pattern) => {pattern}} + +
+
+
+ + + +
+

{language.t("ui.permission.sessionHint")}

+
+ )} +
+
+
+ ) + }} +
+ + {(req) => } + + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 2075ee1b796..0f8ea5bf2d8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -26,7 +26,7 @@ export const ChatView: Component = (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 diff --git a/packages/kilo-vscode/webview-ui/src/context/language.tsx b/packages/kilo-vscode/webview-ui/src/context/language.tsx index 6eef841021c..7e25c50fea4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/language.tsx @@ -193,7 +193,7 @@ interface LanguageContextValue { t: (key: string, params?: UiI18nParams) => string } -const LanguageContext = createContext() +export const LanguageContext = createContext() export function useLanguage() { const ctx = useContext(LanguageContext) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 82962c4a9eb..27f9cc6646b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -138,7 +138,7 @@ interface SessionContextValue { selectCloudSession: (cloudSessionId: string) => void } -const SessionContext = createContext() +export const SessionContext = createContext() export const SessionProvider: 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 new file mode 100644 index 00000000000..2f55722e14e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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 = { ...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, + part: {} as Record, + permission: {} as Record, + 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(), + 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 = (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 ( + + "" as any, + t, + }} + > + "en", t }}> + + + + + +
{props.children}
+
+
+
+
+
+
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx new file mode 100644 index 00000000000..dca22dcd92c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -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(() => , 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 ( + + + + ) + }, +} + +// --------------------------------------------------------------------------- +// 2. Tool with inline permission (bash) +// --------------------------------------------------------------------------- + +export const BashWithPermission: Story = { + name: "Bash + Inline Permission", + render: () => { + const perms = [bashPermission] + const data = dataWith([bashPending], perms) + return ( + + + + ) + }, +} + +// --------------------------------------------------------------------------- +// 3. Permission dock (non-tool bottom prompt) +// --------------------------------------------------------------------------- + +export const PermissionDock: Story = { + name: "Permission Dock", + render: () => { + const perm = dockPermission + return ( + +
+ + 0}> +
+ + {(pattern) => {pattern}} + +
+
+
+
+
+ + + +
+

Approval applies only to the current session

+
+
+
+ ) + }, +} + +// --------------------------------------------------------------------------- +// 4. Tool cards — read, glob, grep, ls +// --------------------------------------------------------------------------- + +export const ToolCards: Story = { + name: "Tool Cards", + render: () => { + const data = dataWith([readCompleted, globCompleted, grepCompleted, lsCompleted]) + return ( + + + + ) + }, +} + +// --------------------------------------------------------------------------- +// 5. Chat idle — prompt input placeholder +// --------------------------------------------------------------------------- + +export const ChatIdle: Story = { + name: "Chat Idle", + render: () => ( + +
+
+
+ Ask anything… (⌘ Enter) +
+
+
+
+ ), +} + +// --------------------------------------------------------------------------- +// 6. Chat busy — working indicator, no prompt +// --------------------------------------------------------------------------- + +export const ChatBusy: Story = { + name: "Chat Busy", + render: () => { + const data = dataWith([textPart]) + return ( + +
+
+ +
+
+ + Thinking… +
+
+
+ ) + }, +} + +// --------------------------------------------------------------------------- +// 7. Multiple tool calls in one assistant message +// --------------------------------------------------------------------------- + +export const MultipleToolCalls: Story = { + name: "Multiple Tool Calls", + render: () => { + const data = dataWith([readCompleted, globCompleted, textPart]) + return ( + + + + ) + }, +} + +// --------------------------------------------------------------------------- +// 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 ( + + + + ) + }, +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 821ebd623dd..bc00739e73e 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -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;