Write Storybook stories and visual regression tests for the Kilo VS Code extension webview UI
Use this skill when the user asks you to add visual regression tests, screenshot tests, or Storybook stories for components in packages/kilo-vscode/.
The VS Code extension uses Storybook + Playwright for visual regression testing:
toHaveScreenshot()tests/visual-regression.spec.ts-snapshots/ (tracked via Git LFS)The test runner at tests/visual-regression.spec.ts is fully automatic — it fetches ALL stories from the Storybook index and creates one Playwright test per story. You do NOT write Playwright test code. You only write stories.
Stories live in packages/kilo-vscode/webview-ui/src/stories/. Existing files and their scope:
| File | Components covered |
|---|---|
agent-manager.stories.tsx | FileTree, DiffPanel, FullScreenDiffView, WorktreeItem |
chat.stories.tsx | ChatView, QuestionDock |
composite.stories.tsx | AssistantMessage with tool cards, permissions, questions |
prompt-input.stories.tsx | PromptInput (sidebar prompt bar) |
settings.stories.tsx | Settings panel, ProvidersTab |
history.stories.tsx | SessionList |
shared.stories.tsx | ModelSelector and shared controls |
Add to an existing file if the component fits. Create a new file only for a genuinely new component area.
Every story file follows this exact structure:
/** @jsxImportSource solid-js */
/**
* Stories for [ComponentName].
*/
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { StoryProviders } from "./StoryProviders"
// Import the component(s) under test
import { MyComponent } from "../components/path/MyComponent"
const meta: Meta = {
title: "MyCategory", // Becomes the snapshot subdirectory name (lowercased)
parameters: { layout: "padded" }, // or "fullscreen"
}
export default meta
type Story = StoryObj
export const MyStoryName: Story = {
name: "MyComponent — description of variant",
render: () => (
<StoryProviders>
<div style={{ "max-height": "400px", overflow: "auto" }}>
<MyComponent someProp="value" />
</div>
</StoryProviders>
),
}
/** @jsxImportSource solid-js \*/** — required for SolidJS JSX compilation.<StoryProviders> — provides all required contexts (VSCode, Server, Config, Provider, Session, I18n, Dialog, Marked, Data, Diff, Code). Without it, components that call useVSCode(), useSession(), etc. will throw.width on the wrapper div. The Playwright viewport is already 420px wide (or 200px for narrow stories). Setting width: "420px" leaves no room for a vertical scrollbar and causes right-side cropping in screenshots. Let the viewport control the width.max-height not height for the wrapper div when you need to constrain vertical size. A fixed height forces a scrollbar even when content is short; max-height avoids unnecessary scrollbars that would eat into the available horizontal space.title determines the snapshot subdirectory. Use PascalCase or slash-notation (e.g., "Composite/Webview"). Playwright transforms it: "Composite/Webview" becomes composite-webview/ in the snapshots folder.{lowercase-title}--{kebab-export-name}. For example, title: "Chat" + export const ChatViewIdle produces ID chat--chat-view-idle.tests/visual-regression.spec.ts-snapshots/{title-slug}/{variant-slug}.png. Example: chat/chat-view-idle-chromium-linux.png.interface StoryProvidersProps {
data?: any // Override mock data (messages, parts, permissions, etc.)
permissions?: PermissionRequest[] // Active permission requests
questions?: QuestionRequest[] // Active question requests
status?: string // Session status: "idle" | "busy"
sessionID?: string // Custom session ID
noPadding?: boolean // Skip the default 12px padding wrapper
}
For stories that need custom session behavior (messages, agents, model overrides), use mockSessionValue() and override the SessionContext:
import { mockSessionValue } from "./StoryProviders"
import { SessionContext } from "../context/session"
export const MyCustomStory: Story = {
name: "Component — custom state",
render: () => {
const session = {
...mockSessionValue({ id: "my-session", status: "idle" }),
messages: () => [{ id: "msg-001" }] as any[],
totalCost: () => 0.0023,
}
return (
<StoryProviders sessionID="my-session" status="idle" noPadding>
<SessionContext.Provider value={session as any}>
<MyComponent />
</SessionContext.Provider>
</StoryProviders>
)
},
}
For stories showing assistant messages with tool parts or permissions, build a custom data object:
import { defaultMockData } from "./StoryProviders"
const SESSION_ID = "story-session-001"
const ASST_MSG_ID = "asst-msg-001"
// Build mock message + parts
const baseMessage = {
id: ASST_MSG_ID,
sessionID: SESSION_ID,
role: "assistant",
// ... see composite.stories.tsx for full shape
}
const myPart = {
id: "part-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
tool: "read",
// ... see composite.stories.tsx for full ToolPart shape
}
function dataWith(parts: any[], permissions?: PermissionRequest[]) {
return {
...defaultMockData,
message: { [SESSION_ID]: [baseMessage] },
part: { [ASST_MSG_ID]: parts },
permission: permissions ? { [SESSION_ID]: permissions } : {},
}
}
export const MyToolStory: Story = {
name: "Tool — with custom parts",
render: () => (
<StoryProviders data={dataWith([myPart])} sessionID={SESSION_ID}>
<AssistantMessage message={baseMessage} />
</StoryProviders>
),
}
For components that should be tested at multiple widths, create separate stories. Stories whose Storybook ID ends in -200 are automatically rendered at 200px width by the test runner:
export const Default420: Story = {
name: "Default — 420px",
render: () => (
<StoryProviders>
<MyComponent />
</StoryProviders>
),
}
export const Default200: Story = {
name: "Default — 200px", // Storybook ID will end in -200
render: () => (
<StoryProviders>
<MyComponent />
</StoryProviders>
),
}
The naming convention with -200 suffix on the export name (e.g., Default200) produces the ID mycategory--default-200, which the test runner detects and uses a 200px viewport for.
The test runner injects CSS to disable all animations and transitions. If a story still produces non-deterministic frames (e.g., a spinner captured at a random rotation), add the story ID to the SKIP set in tests/visual-regression.spec.ts:
const SKIP = new Set<string>(["agentmanager--worktree-item-busy"])
Only skip stories as a last resort. Prefer making the story deterministic (e.g., use a static state instead of an animated one).
Baselines are generated on Linux CI only (font rendering differs on macOS). The CI workflow at .github/workflows/visual-regression.yml auto-runs bun run test:visual:update and commits new baselines to the PR branch.
You do NOT need to generate baseline PNGs locally. Just write the story, push, and CI handles the rest.
To preview stories locally:
# From packages/kilo-vscode/
bun run storybook
# Opens at http://localhost:6007
Snapshots live at packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/:
tests/visual-regression.spec.ts-snapshots/
{title-slug}/
{variant-slug}-chromium-linux.png
The title-slug is derived from the meta title (lowercased, slashes become hyphens). The variant-slug is derived from the story export name (kebab-cased). Chromium and Linux suffixes are appended by Playwright.
Example mapping:
| Meta title | Export name | Snapshot path |
|---|---|---|
"Chat" | ChatViewIdle | chat/chat-view-idle-chromium-linux.png |
"Composite/Webview" | GlobWithPermission | composite-webview/glob-with-permission-chromium-linux.png |
"Prompt Input" | Default200 | prompt-input/default-200-chromium-linux.png |
"AgentManager" | WorktreeItemActive | agentmanager/worktree-item-active-chromium-linux.png |
Key settings in packages/kilo-vscode/playwright.config.ts:
-200): 200x720reducedMotion: "reduce" + injected CSSThe visual-regression.yml workflow triggers on PRs when these paths change:
packages/kilo-ui/**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.ymlCI auto-commits new baselines via Git LFS and fails if screenshots changed, requiring developer review.
Sidebar webview components live in webview-ui/src/components/ and are imported relative to the stories directory:
import { ChatView } from "../components/chat/ChatView"
import { PromptInput } from "../components/chat/PromptInput"
import { AssistantMessage } from "../components/chat/AssistantMessage"
Agent Manager components live in webview-ui/agent-manager/ (one level up from the stories dir):
import { FileTree } from "../../agent-manager/FileTree"
import { DiffPanel } from "../../agent-manager/DiffPanel"
import { WorktreeItem } from "../../agent-manager/WorktreeItem"
import "../../agent-manager/agent-manager.css" // Required for AM component styles
kilo-ui components are imported via deep subpaths:
import { Part } from "@kilocode/kilo-ui/message-part"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Button } from "@kilocode/kilo-ui/button"
SDK types for mock data:
import type { AssistantMessage as SDKAssistantMessage, TextPart, ToolPart } from "@kilocode/sdk/v2"
import type { PermissionRequest, QuestionRequest } from "../types/messages"
The test runner renders every story with dark theme globals:
globals=colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern
The .storybook/preview.tsx applies these via a decorator that calls applyVscodeTheme() / applyKiloTheme() from kilo-ui. Stories do NOT need to handle theming — it happens automatically.
If your story renders AssistantMessage with tool parts, you may need to register VS Code tool overrides at the top of the file (outside any story), as done in composite.stories.tsx:
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
registerVscodeToolOverrides()
This ensures tool cards like bash render with their VS Code-specific expanded/collapsed behavior.
webview-ui/src/stories/StoryProviders (and optionally mockSessionValue, defaultMockData)<StoryProviders> with appropriate props-200 suffix convention