This file provides guidance for AI agents working on this VSCode extension codebase.
Pixel Minion is a VSCode extension for AI-powered image and SVG generation using OpenRouter:
- Image Generation Tab - Text-to-image and image-to-image via OpenRouter image models (Gemini, GPT-5, FLUX)
- SVG Generation Tab - Generate vector graphics as code using text models (Gemini Pro, Claude Opus)
Tech stack:
- TypeScript for type safety
- React 18 for webview UI
- Webpack for bundling (dual entry: extension + webview)
- Jest for testing
- Clean Architecture with layered organization
- OpenRouter API for AI model access
| Layer | Location | Purpose |
|---|---|---|
| Presentation | src/presentation/webview/ |
React components, hooks, styles |
| Application | src/application/ |
Message handlers, view providers |
| Domain | src/domain/ |
Business logic, entities |
| Infrastructure | src/infrastructure/ |
AI clients, secrets, logging, resources |
- Message Envelope - All messages use
MessageEnvelope<TPayload>from@messages - Strategy Pattern -
MessageRouterroutes messages without switch statements - Tripartite Hooks - Domain hooks export State, Actions, Persistence interfaces
- Dependency Injection - Services injected via constructors from
extension.ts - Three-Suite Architecture - Parallel AI infrastructure for Text, Image, and SVG generation
- Thin Handlers - Handlers only route messages, business logic lives in orchestrators
- Rehydration Pattern - Infrastructure rebuilds state from webview history after restart
The extension uses three parallel AI generation suites, each following the same architectural pattern:
┌──────────────────────────────────────────────────────────────────┐
│ Three Parallel Suites │
├──────────────────────────────────────────────────────────────────┤
│ │
│ TEXT SUITE IMAGE SUITE SVG SUITE │
│ ─────────── ──────────── ───────── │
│ TextOrchestrator ImageOrchestrator SVGOrchestrator│
│ │ │ │ │
│ ├── TextClient ├── ImageClient ├── TextClient │
│ │ (static model) │ │ (dynamic) │
│ │ │ │ │
│ └── TextConversation └── ImageConversation └── SVGConversation │
│ Manager Manager Manager │
│ │
└──────────────────────────────────────────────────────────────────┘
Each suite consists of three layers:
-
Orchestrator - Coordinates between client and conversation manager
- Provides clean interface for handlers
- Handles conversation lifecycle
- Manages state transitions
- Supports re-hydration after extension restart
-
Client - Communicates with AI service provider
- Implements provider-specific API calls
- Handles authentication via SecretStorageService
- Returns structured results
-
ConversationManager - Maintains conversation state
- Tracks message history
- Formats messages for API calls
- Rebuilds state from history (re-hydration)
- Purpose: Chat/conversation functionality (no UI tab today; used for helpers)
- Client:
OpenRouterTextClient(static model set at construction; reset when model changes) - Messages:
TextMessage[]with role-based structure - State:
TextConversationwith turn counting and max turns limit - Rehydration: Supported in infrastructure (handler/orchestrator) but currently not wired from the webview
- Default:
max_tokens: 48000
- Purpose: Image generation (text-to-image, image-to-image)
- Client:
OpenRouterImageClient - Messages:
ImageConversationMessage[]with multimodal content - State: Tracks prompts, generated images, seeds, aspect ratio
- Special: Re-hydration support for conversation continuation; keeps model/aspect per conversation (changing settings does not retarget an in-flight thread)
- Purpose: SVG code generation using text models
- Client:
OpenRouterDynamicTextClient(model passed per-request to avoid race conditions) - Messages:
TextMessage[]with multimodal content (images optional) - State: Tracks prompts, SVG code, aspect ratio
- Special: Extracts SVG code from markdown responses; re-hydration supported and uses stored model/aspect per conversation
Orchestrators receive clients via setClient() method:
// In MessageHandler.ts
const imageOrchestrator = new ImageOrchestrator(logger);
imageOrchestrator.setClient(new OpenRouterImageClient(secretStorage, logger));
const svgOrchestrator = new SVGOrchestrator(logger);
svgOrchestrator.setClient(new OpenRouterDynamicTextClient(secretStorage, logger));
// Handlers receive orchestrators
this.imageGenerationHandler = new ImageGenerationHandler(
postMessage,
imageOrchestrator, // Injected!
logger
);Handlers are THIN - they only route messages to orchestrators:
// Handler receives orchestrator, doesn't create clients
export class ImageGenerationHandler {
constructor(
private readonly postMessage: (message: MessageEnvelope) => void,
private readonly orchestrator: ImageOrchestrator, // Injected!
private readonly logger: LoggingService
) {}
// Handler only routes messages - NO business logic
async handleGenerationRequest(message: MessageEnvelope<...>): Promise<void> {
const result = await this.orchestrator.generateImage(...);
this.postMessage(createEnvelope(..., result));
}
}The extension maintains conversation history in two separate stores:
-
Presentation Layer (webview):
ConversationTurn[]- UI-focused structure
- Persists across webview reloads via
vscode.setState() - Contains user prompts, assistant responses, metadata
-
Infrastructure Layer (extension):
TextMessage[]orImageConversationMessage[]- API-focused structure
- Ephemeral (in-memory
Map) - Lost when extension restarts
- Rebuilt via re-hydration when needed
Extension restarts lose infrastructure state, but webview state persists. Re-hydration rebuilds infrastructure state from webview history:
// Webview sends history with continuation requests
const continueChat = useCallback((prompt: string) => {
const history = conversationHistory.map(turn => ({
prompt: turn.prompt,
images: turn.images,
}));
postMessage({
type: MessageType.CONTINUE_CONVERSATION,
payload: {
prompt,
conversationId,
history, // Self-contained request enables re-hydration
model,
aspectRatio,
}
});
}, [conversationId, conversationHistory, model, aspectRatio]);
// Handler re-hydrates if conversation lost
async handleContinueRequest(message) {
let conversation = this.orchestrator.getConversation(conversationId);
// If conversation not found but history provided, re-hydrate
if (!conversation && history?.length) {
conversation = await this.orchestrator.continueConversation(
conversationId,
prompt,
history, // Orchestrator rebuilds state
model,
aspectRatio
);
}
}Benefits:
- Webview doesn't need to know if extension restarted
- Handler uses existing conversation if available (ignores history)
- Handler rebuilds from history if conversation lost
- AI model gets full context even after restart
Use these path aliases (defined in tsconfig.json):
import { MessageType } from '@messages'; // Message types
import { SecretStorageService } from '@secrets'; // Secret storage
import { TextOrchestrator, ImageOrchestrator, SVGOrchestrator } from '@ai';
import { OpenRouterTextClient, OpenRouterDynamicTextClient } from '@ai';
import { OpenRouterImageClient } from '@ai';
import { LoggingService } from '@logging'; // Logging service
import { OPENROUTER_CONFIG } from '@providers'; // Provider configs
import { HelloWorldHandler } from '@handlers/domain/HelloWorldHandler';
import { Button } from '@components/common'; // React components
import { useSettings } from '@hooks/domain/useSettings';- Config keys use the
pixelMinion.*namespace. Model selections arepixelMinion.imageModel,pixelMinion.svgModel, andpixelMinion.openRouterModel(text). - Settings changes flow both ways: VS Code settings → webview (via SETTINGS_DATA broadcast on config change) and webview → VS Code (via
UPDATE_SETTINGfrom hooks). - Text model changes reset the text client so the next turn uses the new model. Image/SVG conversations keep their initial model/aspect; start a new conversation to change it.
- Requires VS Code
^1.93.0(Node 18) for built-infetch. - Activation events use
onView:pixelMinion.mainViewand pixelMinion commands; container/view IDs arepixel-minion/pixelMinion.mainView.
-
Add enum value to
src/shared/types/messages/base.ts:export enum MessageType { ...existing, MY_NEW_TYPE = 'MY_NEW_TYPE', }
-
Create payload interface in appropriate domain file
-
Export from
src/shared/types/messages/index.ts
- Create handler class in
src/application/handlers/domain/ - Inject dependencies via constructor:
- Always:
postMessage,logger - If using AI: Inject orchestrator (NOT client)
- Example:
new MyHandler(postMessage, myOrchestrator, logger)
- Always:
- Keep handlers THIN - only route messages to orchestrators
- Register routes in
MessageHandler.ts - Add tests in
src/__tests__/application/handlers/domain/
Example Thin Handler:
export class MyFeatureHandler {
constructor(
private readonly postMessage: (message: MessageEnvelope) => void,
private readonly orchestrator: MyOrchestrator, // Injected!
private readonly logger: LoggingService
) {}
async handleRequest(message: MessageEnvelope<MyPayload>): Promise<void> {
try {
// NO business logic here - delegate to orchestrator
const result = await this.orchestrator.doSomething(message.payload);
this.postMessage(createEnvelope(
MessageType.MY_RESPONSE,
'extension.myFeature',
result,
message.correlationId
));
} catch (error) {
this.logger.error('Request failed', error);
this.postMessage(createEnvelope(
MessageType.ERROR,
'extension.myFeature',
{ message: error.message },
message.correlationId
));
}
}
}Follow tripartite pattern:
export interface MyState { ... }
export interface MyActions { ... }
export interface MyPersistence { ... }
export type UseMyReturn = MyState & MyActions & { persistedState: MyPersistence };- Create in appropriate directory under
src/presentation/webview/components/ - Use VSCode CSS variables for theming
- Export from barrel file (
index.ts)
- Unit tests go in
src/__tests__/mirroring source structure - Use the VSCode mocks from
src/__tests__/setup.ts - Test handlers independently from VSCode APIs
- Path aliases work in tests via
jest.config.jsmoduleNameMapper - Mock LoggingService with
{ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | Button.tsx, SettingsView.tsx |
| Hooks | camelCase with use prefix |
useSettings.ts |
| Handlers | PascalCase with Handler suffix |
SettingsHandler.ts |
| Message types | PascalCase | SettingsPayload |
| CSS | kebab-case | button.css |
- API Keys: Always use
SecretStorageService, never store in settings - Webview CSP: HTML uses nonce-based Content Security Policy
- Message Sources: Check
message.sourceto prevent echo loops
- Never use
console.log/error/warnin extension code - useLoggingServiceinstead - Exception:
consoleis only acceptable inextension.tsactivation beforeLoggingServiceis created (fallback if extension fails to load) - Inject LoggingService via constructor for all handlers and services
- Log levels: Use
debugfor development details,infofor general events,warnfor potential issues,errorfor failures - All logs appear in VSCode's Output panel under "Pixel Minion"
npm run watch # Development with hot reload
npm run build # Production build
npm test # Run tests
npm run lint # Check code style- Messages: Update both handler (extension) and hook (webview)
- Settings: Update package.json, handler, hook, and view
- Components: Ensure proper CSS variable usage for theming
- Tests: Add/update tests for new functionality
Model selection uses a provider interface for extensibility:
// src/shared/types/providers.ts
interface ProviderConfig {
id: string;
displayName: string;
baseUrl: string;
models: Record<GenerationType, ModelDefinition[]>; // 'image' | 'svg'
}
// Usage: OPENROUTER_CONFIG.models.image for Image tab dropdown
// Usage: OPENROUTER_CONFIG.models.svg for SVG tab dropdownSee docs/adr/ for architectural decisions. Key ADRs:
- ADR-001: Pixel Minion Architecture - Two-tab design, provider interface, message types
- ADR-002: Three-Suite AI Infrastructure - Text, Image, and SVG generation suites with orchestrator pattern
This section provides step-by-step procedures for common development tasks based on evolved patterns in the codebase.
Complete procedure for adding a new tab to the extension UI.
Create a new hook file in src/presentation/webview/hooks/domain/ following the tripartite pattern:
// src/presentation/webview/hooks/domain/useMyFeature.ts
import { useState, useCallback } from 'react';
import { MessageType } from '@messages';
// State interface - all observable state
export interface MyFeatureState {
data: string | null;
isLoading: boolean;
}
// Actions interface - all user-triggered actions
export interface MyFeatureActions {
doSomething: (input: string) => void;
reset: () => void;
}
// Persistence interface - state to save/restore
export interface MyFeaturePersistence {
data: string | null;
}
export type UseMyFeatureReturn = MyFeatureState & MyFeatureActions & {
persistedState: MyFeaturePersistence;
};
export const useMyFeature = (postMessage: (msg: any) => void): UseMyFeatureReturn => {
const [data, setData] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const doSomething = useCallback((input: string) => {
setIsLoading(true);
postMessage({
type: MessageType.MY_FEATURE_REQUEST,
payload: { input }
});
}, [postMessage]);
const reset = useCallback(() => {
setData(null);
setIsLoading(false);
}, []);
return {
// State
data,
isLoading,
// Actions
doSomething,
reset,
// Persistence
persistedState: {
data
}
};
};Export from barrel file:
// src/presentation/webview/hooks/domain/index.ts
export * from './useMyFeature';Create a view component in src/presentation/webview/components/views/:
// src/presentation/webview/components/views/MyFeatureView.tsx
import React from 'react';
import { UseMyFeatureReturn } from '@hooks/domain';
import { LoadingIndicator } from '@components/shared';
import './my-feature-view.css';
interface MyFeatureViewProps {
myFeature: UseMyFeatureReturn;
}
export const MyFeatureView: React.FC<MyFeatureViewProps> = ({ myFeature }) => {
const { data, isLoading, doSomething, reset } = myFeature;
return (
<div className="my-feature-view">
{/* Fixed top section */}
<div className="my-feature-header">
<h2>My Feature</h2>
<button onClick={reset}>Reset</button>
</div>
{/* Scrolling content section */}
<div className="my-feature-content">
{data && <div className="result">{data}</div>}
</div>
{/* Fixed bottom section */}
<div className="my-feature-controls">
<button onClick={() => doSomething('test')}>Do Something</button>
{isLoading && <LoadingIndicator />}
</div>
</div>
);
};Create CSS file:
/* src/presentation/webview/components/views/my-feature-view.css */
.my-feature-view {
display: flex;
flex-direction: column;
height: 100%;
}
.my-feature-header {
flex-shrink: 0;
padding: 8px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.my-feature-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.my-feature-controls {
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--vscode-panel-border);
}Export from barrel file:
// src/presentation/webview/components/views/index.ts
export * from './MyFeatureView';Add tab to src/presentation/webview/components/layout/TabBar.tsx:
export type TabId = 'image' | 'svg' | 'myfeature';
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'image', label: 'Image' },
{ id: 'svg', label: 'SVG' },
{ id: 'myfeature', label: 'My Feature' }
];Update src/presentation/webview/App.tsx to handle messages at the app level:
// Add hook instance
const myFeature = useMyFeature(postMessage);
// Add message handler
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data;
// Handle my feature responses
if (message.type === MessageType.MY_FEATURE_RESPONSE) {
// Update state via setter from hook
setMyFeatureData(message.payload.data);
setMyFeatureLoading(false);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
// Add to tab rendering
{activeTab === 'myfeature' && <MyFeatureView myFeature={myFeature} />}Create handler in src/application/handlers/domain/:
// src/application/handlers/domain/MyFeatureHandler.ts
import { MessageHandler } from '@handlers/MessageHandler';
import { MessageEnvelope, MessageType, MyFeatureRequestPayload } from '@messages';
import { LoggingService } from '@logging';
export class MyFeatureHandler extends MessageHandler {
constructor(
postMessage: (message: MessageEnvelope<any>) => void,
private readonly logger: LoggingService
) {
super(postMessage);
}
async handle(message: MessageEnvelope<MyFeatureRequestPayload>): Promise<void> {
this.logger.info('Handling my feature request', { input: message.payload.input });
try {
const result = await this.processRequest(message.payload);
this.postMessage({
type: MessageType.MY_FEATURE_RESPONSE,
payload: { data: result },
correlationId: message.correlationId
});
} catch (error) {
this.logger.error('My feature request failed', error);
this.postMessage({
type: MessageType.MY_FEATURE_RESPONSE,
payload: { error: error.message },
correlationId: message.correlationId
});
}
}
private async processRequest(payload: MyFeatureRequestPayload): Promise<string> {
// Implementation
return 'result';
}
}Register in src/application/handlers/MessageHandler.ts:
this.router.registerRoute(MessageType.MY_FEATURE_REQUEST, this.myFeatureHandler);Update webview state manager to include new tab's state:
// In App.tsx
const persistedState = {
image: imageGeneration.persistedState,
svg: svgGeneration.persistedState,
myFeature: myFeature.persistedState
};
// Restore state on mount
useEffect(() => {
const savedState = vscode.getState();
if (savedState?.myFeature) {
// Restore myFeature state
}
}, []);Complete procedure for adding request/response message types with correlation support.
Add to src/shared/types/messages/base.ts:
export enum MessageType {
// Existing types...
// My Feature
MY_FEATURE_REQUEST = 'MY_FEATURE_REQUEST',
MY_FEATURE_RESPONSE = 'MY_FEATURE_RESPONSE',
}Create payload interfaces in appropriate domain file (e.g., src/shared/types/messages/myfeature.ts):
// Request payload
export interface MyFeatureRequestPayload {
input: string;
options?: {
flag: boolean;
};
}
// Response payload
export interface MyFeatureResponsePayload {
data?: string;
error?: string;
}Add to src/shared/types/messages/index.ts:
export * from './myfeature';When sending requests, generate and store correlation ID:
// In hook
const doSomething = useCallback((input: string) => {
const correlationId = crypto.randomUUID();
setCorrelationId(correlationId);
setIsLoading(true);
postMessage({
type: MessageType.MY_FEATURE_REQUEST,
payload: { input },
correlationId
});
}, [postMessage]);Message handlers should be registered in App.tsx, NOT at component level:
// In App.tsx
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data;
// Prevent echo
if (message.source === 'webview') {
return;
}
switch (message.type) {
case MessageType.MY_FEATURE_RESPONSE:
handleMyFeatureResponse(message);
break;
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);Register in src/application/handlers/MessageHandler.ts:
this.router.registerRoute(
MessageType.MY_FEATURE_REQUEST,
this.myFeatureHandler
);Always check message source to prevent echo loops:
// In webview message handler
if (message.source === 'webview') {
return; // Ignore our own messages
}In extension handler, always set source:
this.postMessage({
type: MessageType.MY_FEATURE_RESPONSE,
payload: { data: result },
correlationId: message.correlationId,
source: 'extension'
});Complete procedure for adding new configuration settings.
Add to package.json under contributes.configuration.properties:
{
"contributes": {
"configuration": {
"properties": {
"pixelMinion.myFeature.enabled": {
"type": "boolean",
"default": true,
"description": "Enable my feature"
},
"pixelMinion.myFeature.threshold": {
"type": "number",
"default": 0.5,
"minimum": 0,
"maximum": 1,
"description": "Threshold for my feature"
}
}
}
}
}Update src/shared/types/messages/settings.ts:
export interface SettingsPayload {
// Existing settings...
// My Feature settings
myFeatureEnabled?: boolean;
myFeatureThreshold?: number;
}Update src/application/handlers/domain/SettingsHandler.ts:
async handle(message: MessageEnvelope<void>): Promise<void> {
const config = vscode.workspace.getConfiguration('pixelMinion');
const settings: SettingsPayload = {
// Existing settings...
myFeatureEnabled: config.get('myFeature.enabled', true),
myFeatureThreshold: config.get('myFeature.threshold', 0.5)
};
this.postMessage({
type: MessageType.SETTINGS_RESPONSE,
payload: settings,
correlationId: message.correlationId
});
}Update src/presentation/webview/hooks/domain/useSettings.ts:
export interface SettingsState {
// Existing state...
myFeatureEnabled: boolean;
myFeatureThreshold: number;
}
// In hook
const [myFeatureEnabled, setMyFeatureEnabled] = useState(true);
const [myFeatureThreshold, setMyFeatureThreshold] = useState(0.5);
// In message handler
if (message.type === MessageType.SETTINGS_RESPONSE) {
setMyFeatureEnabled(message.payload.myFeatureEnabled ?? true);
setMyFeatureThreshold(message.payload.myFeatureThreshold ?? 0.5);
}For API keys and sensitive data, use SecretStorageService instead:
// In handler
export class MyFeatureHandler extends MessageHandler {
constructor(
postMessage: (message: MessageEnvelope<any>) => void,
private readonly logger: LoggingService,
private readonly secretStorage: SecretStorageService
) {
super(postMessage);
}
async handle(message: MessageEnvelope<SaveApiKeyPayload>): Promise<void> {
await this.secretStorage.storeSecret('myFeature.apiKey', message.payload.apiKey);
this.logger.info('API key saved securely');
}
async getApiKey(): Promise<string | undefined> {
return this.secretStorage.getSecret('myFeature.apiKey');
}
}Never store sensitive data in workspace configuration:
// WRONG - Never do this
config.update('myFeature.apiKey', apiKey);
// RIGHT - Use SecretStorageService
await secretStorage.storeSecret('myFeature.apiKey', apiKey);Complete guide for creating and organizing components.
Components are organized by purpose:
src/presentation/webview/components/
├── common/ # Reusable UI primitives (Button, Input, Dropdown)
├── layout/ # Layout components (TabBar, Container)
├── shared/ # Shared domain-agnostic components (LoadingIndicator, ErrorMessage)
├── image/ # Image-specific components (ImageResult, ImageControls)
├── svg/ # SVG-specific components (SvgPreview, SvgControls)
└── views/ # Top-level tab views (ImageView, SvgView)
Low-level reusable UI elements:
// src/presentation/webview/components/common/Button.tsx
import React from 'react';
import './button.css';
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
onClick,
children,
variant = 'primary',
disabled = false
}) => {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};CSS using VSCode variables:
/* src/presentation/webview/components/common/button.css */
.btn {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 14px;
cursor: pointer;
}
.btn:hover {
background-color: var(--vscode-button-hoverBackground);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}Components used across multiple domains:
// src/presentation/webview/components/shared/LoadingIndicator.tsx
import React from 'react';
import './loading-indicator.css';
interface LoadingIndicatorProps {
message?: string;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ message }) => {
return (
<div className="loading-indicator">
<div className="spinner"></div>
{message && <span className="loading-message">{message}</span>}
</div>
);
};Components specific to a feature domain:
// src/presentation/webview/components/image/ImageResult.tsx
import React from 'react';
import { ImageGenerationResult } from '@messages';
import './image-result.css';
interface ImageResultProps {
result: ImageGenerationResult;
onRefine: () => void;
onSave: () => void;
}
export const ImageResult: React.FC<ImageResultProps> = ({
result,
onRefine,
onSave
}) => {
return (
<div className="image-result">
<img src={result.imageUrl} alt={result.prompt} />
<div className="image-actions">
<button onClick={onRefine}>Refine</button>
<button onClick={onSave}>Save</button>
</div>
</div>
);
};Views receive hook instances as props:
// src/presentation/webview/components/views/MyFeatureView.tsx
import React from 'react';
import { UseMyFeatureReturn } from '@hooks/domain';
interface MyFeatureViewProps {
myFeature: UseMyFeatureReturn; // Receive entire hook instance
}
export const MyFeatureView: React.FC<MyFeatureViewProps> = ({ myFeature }) => {
// Destructure what you need
const { data, isLoading, doSomething } = myFeature;
return (
<div className="my-feature-view">
{/* Use hook state and actions */}
</div>
);
};Always use VSCode theme variables for consistent theming:
/* Colors */
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-panel-border);
/* Buttons */
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
/* Inputs */
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
/* Links */
color: var(--vscode-textLink-foreground);
/* Focus */
outline: 1px solid var(--vscode-focusBorder);
/* Status colors */
color: var(--vscode-errorForeground);
color: var(--vscode-notificationsWarningIcon-foreground);
color: var(--vscode-terminal-ansiGreen);Pattern 1: Receive Hook Instance (Preferred for Views)
interface MyViewProps {
myFeature: UseMyFeatureReturn; // Pass entire hook
}
// Usage
<MyView myFeature={myFeature} />Pattern 2: Receive Individual Props (For Reusable Components)
interface MyComponentProps {
data: string;
onAction: () => void;
isLoading: boolean;
}
// Usage
<MyComponent
data={myFeature.data}
onAction={myFeature.doSomething}
isLoading={myFeature.isLoading}
/>Always export from barrel files:
// src/presentation/webview/components/common/index.ts
export * from './Button';
export * from './Input';
export * from './Dropdown';
// src/presentation/webview/components/index.ts
export * from './common';
export * from './layout';
export * from './shared';
export * from './views';Guide for implementing loading states with proper layout to avoid shifts.
Use a three-zone flex layout to prevent content jumps:
<div className="container">
{/* Zone 1: Fixed top section */}
<div className="header">
<h2>Title</h2>
</div>
{/* Zone 2: Scrolling content */}
<div className="content">
{/* Main content here */}
</div>
{/* Zone 3: Fixed bottom section */}
<div className="controls">
<button onClick={generate}>Generate</button>
{isLoading && <LoadingIndicator />}
</div>
</div>CSS for three-zone layout:
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
flex-shrink: 0; /* Don't shrink */
padding: 8px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.content {
flex: 1; /* Take remaining space */
overflow-y: auto; /* Scroll independently */
padding: 8px;
}
.controls {
flex-shrink: 0; /* Don't shrink */
padding: 8px;
border-top: 1px solid var(--vscode-panel-border);
}Place loading indicator in fixed bottom zone to avoid layout shift:
// GOOD - Loading indicator in fixed bottom zone
<div className="controls">
<button onClick={generate} disabled={isLoading}>
Generate
</button>
{isLoading && <LoadingIndicator message="Generating..." />}
</div>
// BAD - Loading indicator in scrolling content causes shift
<div className="content">
{isLoading && <LoadingIndicator />}
{result && <Result data={result} />}
</div>Pattern for managing loading state:
export const useMyFeature = (postMessage: (msg: any) => void) => {
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<Result | null>(null);
const [error, setError] = useState<string | null>(null);
const doSomething = useCallback(async (input: string) => {
setIsLoading(true);
setError(null);
postMessage({
type: MessageType.MY_FEATURE_REQUEST,
payload: { input }
});
}, [postMessage]);
// Message handler updates loading state
const handleResponse = useCallback((message: MessageEnvelope<MyFeatureResponsePayload>) => {
setIsLoading(false);
if (message.payload.error) {
setError(message.payload.error);
} else {
setResult(message.payload.data);
}
}, []);
return {
isLoading,
result,
error,
doSomething,
handleResponse
};
};For features with multiple concurrent operations:
interface LoadingStates {
generating: boolean;
saving: boolean;
loading: boolean;
}
const [loadingStates, setLoadingStates] = useState<LoadingStates>({
generating: false,
saving: false,
loading: false
});
// Update individual states
const setGenerating = (value: boolean) => {
setLoadingStates(prev => ({ ...prev, generating: value }));
};Guide for implementing conversational/chat interfaces.
Standard structure for conversation turns:
// src/shared/types/messages/conversation.ts
export interface ConversationTurn {
id: string; // Unique turn ID
timestamp: Date; // When turn was created
userPrompt: string; // User's input
assistantResponse?: string; // AI response (optional, pending)
status: 'pending' | 'complete' | 'error';
error?: string; // Error message if failed
metadata?: {
model?: string;
tokensUsed?: number;
imageUrl?: string; // For image generation
seed?: number; // For reproducibility
[key: string]: any; // Extensible
};
}Hook pattern for managing conversation history:
// src/presentation/webview/hooks/domain/useConversation.ts
export interface ConversationState {
turns: ConversationTurn[];
pendingPrompt: string | null;
isGenerating: boolean;
}
export interface ConversationActions {
addTurn: (prompt: string) => void;
updateTurn: (id: string, updates: Partial<ConversationTurn>) => void;
clearConversation: () => void;
setConversationTitle: (title: string) => void;
}
export interface ConversationPersistence {
turns: ConversationTurn[];
title?: string;
}
export const useConversation = (postMessage: (msg: any) => void) => {
const [turns, setTurns] = useState<ConversationTurn[]>([]);
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [title, setTitle] = useState<string>('New Conversation');
const addTurn = useCallback((prompt: string) => {
const turn: ConversationTurn = {
id: crypto.randomUUID(),
timestamp: new Date(),
userPrompt: prompt,
status: 'pending'
};
setTurns(prev => [...prev, turn]);
setPendingPrompt(prompt);
setIsGenerating(true);
postMessage({
type: MessageType.GENERATE_RESPONSE,
payload: {
prompt,
conversationHistory: turns
},
correlationId: turn.id
});
}, [postMessage, turns]);
const updateTurn = useCallback((id: string, updates: Partial<ConversationTurn>) => {
setTurns(prev =>
prev.map(turn =>
turn.id === id ? { ...turn, ...updates } : turn
)
);
if (updates.status === 'complete' || updates.status === 'error') {
setIsGenerating(false);
setPendingPrompt(null);
}
}, []);
const clearConversation = useCallback(() => {
setTurns([]);
setPendingPrompt(null);
setIsGenerating(false);
setTitle('New Conversation');
}, []);
const setConversationTitle = useCallback((newTitle: string) => {
setTitle(newTitle);
}, []);
return {
turns,
pendingPrompt,
isGenerating,
title,
addTurn,
updateTurn,
clearConversation,
setConversationTitle,
persistedState: {
turns,
title
}
};
};Track pending prompts to show optimistic UI:
// In view component
{conversation.pendingPrompt && (
<div className="pending-turn">
<div className="user-message">{conversation.pendingPrompt}</div>
<div className="assistant-message">
<LoadingIndicator message="Generating response..." />
</div>
</div>
)}
{conversation.turns.map(turn => (
<div key={turn.id} className="conversation-turn">
<div className="user-message">{turn.userPrompt}</div>
{turn.assistantResponse && (
<div className="assistant-message">{turn.assistantResponse}</div>
)}
{turn.status === 'error' && (
<div className="error-message">{turn.error}</div>
)}
</div>
))}Save and restore conversation state:
// In App.tsx
useEffect(() => {
const persistedState = {
conversation: conversation.persistedState
};
vscode.setState(persistedState);
}, [conversation.persistedState]);
// Restore on mount
useEffect(() => {
const savedState = vscode.getState();
if (savedState?.conversation) {
// Restore turns
savedState.conversation.turns.forEach((turn: ConversationTurn) => {
conversation.updateTurn(turn.id, turn);
});
// Restore title
if (savedState.conversation.title) {
conversation.setConversationTitle(savedState.conversation.title);
}
}
}, []);Reference design document:
// See docs/chat-thread-example-design.md for complete chat UI design
// Key components:
// - ConversationHeader: Title, date, clear button
// - ConversationThread: Scrolling message list
// - ConversationTurnItem: Individual turn display
// - ConversationInput: Input area with send buttonExample conversation header:
// src/presentation/webview/components/shared/ConversationHeader.tsx
import React from 'react';
import './conversation-header.css';
interface ConversationHeaderProps {
title: string;
date: Date;
onClear: () => void;
}
export const ConversationHeader: React.FC<ConversationHeaderProps> = ({
title,
date,
onClear
}) => {
return (
<div className="conversation-header">
<div className="conversation-info">
<h3>{title}</h3>
<span className="conversation-date">
{date.toLocaleDateString()}
</span>
</div>
<button className="clear-button" onClick={onClear}>
Clear
</button>
</div>
);
};Extension-side conversation state is ephemeral (in-memory Map). When the extension restarts, the webview's persisted state survives but the handler loses its conversation context.
Solution: Self-contained requests with automatic re-hydration.
// Webview: Always include history in continuation requests
const continueChat = useCallback((chatPrompt: string) => {
// Build history for re-hydration (sent every time, handler ignores if not needed)
const history = conversationHistory.map(turn => ({
prompt: turn.prompt,
response: turn.response, // or images for image generation
}));
vscode.postMessage(
createEnvelope(MessageType.CONTINUE_CONVERSATION, 'webview.chat', {
prompt: chatPrompt,
conversationId,
history, // Self-contained request enables re-hydration
model,
})
);
}, [conversationId, conversationHistory, model, vscode]);
// Handler: Re-hydrate if conversation lost
async handleContinueRequest(message) {
let conversation = this.conversations.get(conversationId);
// If conversation not found but history provided, re-hydrate
if (!conversation && history?.length) {
this.logger.info(`Conversation ${conversationId} not found, re-hydrating`);
conversation = this.rehydrateFromHistory(conversationId, history, model);
}
// Continue with conversation (existing or re-hydrated)
// ...
}Benefits:
- Webview doesn't need to know if extension restarted
- Handler uses existing conversation if available (ignores history)
- Handler rebuilds from history if conversation lost
- AI model gets full context even after restart
See docs/conversation-architecture.md for detailed architecture.
Guide for integrating AI clients with the three-suite architecture.
The extension provides three client interfaces:
- TextClient - For text completion (chat, SVG code generation)
- ImageGenerationClient - For image generation
- OpenRouterDynamicTextClient - Text client with dynamic model selection
For text generation where model is fixed at construction:
// src/infrastructure/ai/clients/OpenRouterTextClient.ts
import { TextClient, TextMessage, TextCompletionResult } from './TextClient';
export class OpenRouterTextClient implements TextClient {
constructor(
private readonly apiKey: string,
private readonly model: string = 'anthropic/claude-sonnet-4'
) {}
getModel(): string {
return this.model;
}
async createCompletion(
messages: TextMessage[],
options?: TextCompletionOptions
): Promise<TextCompletionResult> {
// ... OpenRouter API call with this.model
// Default: max_tokens: 48000
}
}For text generation where model changes per request (used by SVG suite):
// src/infrastructure/ai/clients/OpenRouterDynamicTextClient.ts
import { TextClient } from './TextClient';
import { SecretStorageService } from '@secrets';
import { LoggingService } from '@logging';
export class OpenRouterDynamicTextClient implements TextClient {
private currentModel: string;
constructor(
private readonly secretStorage: SecretStorageService,
private readonly logger: LoggingService,
defaultModel: string = 'anthropic/claude-sonnet-4'
) {
this.currentModel = defaultModel;
}
/**
* @deprecated Use createCompletion() with model in options instead.
* This method exists for backwards compatibility but may cause race conditions
* in concurrent scenarios. Pass model directly in createCompletion() options.
*/
setModel(model: string): void {
this.currentModel = model;
this.logger.debug(`Model set to: ${model}`);
}
getModel(): string {
return this.currentModel;
}
async createCompletion(messages, options?): Promise<TextCompletionResult> {
const apiKey = await this.secretStorage.getApiKey();
// Use model from options if provided, otherwise fall back to currentModel
const model = options?.model ?? this.currentModel;
// ... OpenRouter API call with model variable
// Default: max_tokens: 48000
}
}Race Condition Prevention: Always pass the model in createCompletion() options rather than using setModel(). This prevents race conditions when multiple requests are made concurrently or when a conversation is re-hydrated.
Old pattern (deprecated - causes race conditions):
client.setModel('anthropic/claude-sonnet-4');
const result = await client.createCompletion(messages);New pattern (recommended):
const result = await client.createCompletion(messages, {
model: 'anthropic/claude-sonnet-4',
maxTokens: 48000
});For image generation:
// src/infrastructure/ai/clients/OpenRouterImageClient.ts
import { ImageGenerationClient, ImageGenerationRequest, ImageGenerationResult } from './ImageGenerationClient';
import { SecretStorageService } from '@secrets';
import { LoggingService } from '@logging';
export class OpenRouterImageClient implements ImageGenerationClient {
constructor(
private readonly secretStorage: SecretStorageService,
private readonly logger: LoggingService
) {}
async generateImages(request: ImageGenerationRequest): Promise<ImageGenerationResult> {
const apiKey = await this.secretStorage.getApiKey();
// ... OpenRouter image generation API call
// Uses modalities: ['image', 'text']
// Uses image_config: { aspect_ratio: request.aspectRatio }
}
async isConfigured(): Promise<boolean> {
return this.secretStorage.hasApiKey();
}
}Wire up all three suites in MessageHandler constructor:
// src/application/handlers/MessageHandler.ts
import { ImageOrchestrator, SVGOrchestrator } from '@ai';
import { OpenRouterImageClient, OpenRouterDynamicTextClient } from '@ai';
export class MessageHandler {
constructor(
private readonly postMessage: (message: MessageEnvelope) => void,
secretStorage: SecretStorageService,
private readonly logger: LoggingService
) {
// IMAGE SUITE
const imageOrchestrator = new ImageOrchestrator(logger);
imageOrchestrator.setClient(new OpenRouterImageClient(secretStorage, logger));
this.imageGenerationHandler = new ImageGenerationHandler(
postMessage,
imageOrchestrator, // Injected!
logger
);
// SVG SUITE
const svgOrchestrator = new SVGOrchestrator(logger);
svgOrchestrator.setClient(new OpenRouterDynamicTextClient(secretStorage, logger));
this.svgGenerationHandler = new SVGGenerationHandler(
postMessage,
svgOrchestrator, // Injected!
logger
);
// Register routes...
}
}Handlers ONLY route messages - all business logic in orchestrators:
// src/application/handlers/domain/ImageGenerationHandler.ts
export class ImageGenerationHandler {
constructor(
private readonly postMessage: (message: MessageEnvelope) => void,
private readonly orchestrator: ImageOrchestrator, // Injected!
private readonly logger: LoggingService
) {}
async handleGenerationRequest(message: MessageEnvelope<ImageGenerationRequestPayload>): Promise<void> {
const { prompt, model, aspectRatio, referenceImages, conversationId, seed } = message.payload;
try {
// Delegate to orchestrator - NO business logic here
const result = await this.orchestrator.generateImage(prompt, {
model,
aspectRatio,
seed,
referenceImages,
}, conversationId);
// Transform and send response
const images = this.transformToGeneratedImages(result.result, result.conversationId, result.turnNumber, prompt);
this.postMessage(createEnvelope<ImageGenerationResponsePayload>(
MessageType.IMAGE_GENERATION_RESPONSE,
'extension.imageGeneration',
{ conversationId: result.conversationId, images, turnNumber: result.turnNumber },
message.correlationId
));
} catch (error) {
this.sendError(error, message.correlationId);
}
}
}// Create conversation
const conversationId = textOrchestrator.startConversation(systemPrompt);
// Send message
const result = await textOrchestrator.sendMessage(
conversationId,
userMessage,
{ temperature: 0.7, maxTokens: 48000 }
);
// Single message (no conversation state)
const result = await textOrchestrator.sendSingleMessage(
userMessage,
systemPrompt,
{ temperature: 0.7 }
);// Generate image (new or continue)
const result = await imageOrchestrator.generateImage(prompt, {
model: 'google/gemini-2.5-flash-image',
aspectRatio: '16:9',
seed: 12345,
referenceImages: [base64Image1, base64Image2],
}, conversationId);
// Continue conversation with re-hydration
const result = await imageOrchestrator.continueConversation(
conversationId,
prompt,
history, // Re-hydration data from webview
model,
aspectRatio
);// Generate SVG (new or continue)
const result = await svgOrchestrator.generateSVG(prompt, {
model: 'google/gemini-3-pro-preview',
aspectRatio: '1:1',
referenceImage: base64Image,
}, conversationId);
// Continue existing conversation
const result = await svgOrchestrator.continueSVG(conversationId, prompt);Image and SVG orchestrators support re-hydration after extension restart:
// In handler - check if conversation exists
let conversation = this.orchestrator.getConversation(conversationId);
// If not found but history provided, re-hydrate
if (!conversation && history?.length) {
this.logger.info(`Re-hydrating conversation ${conversationId}`);
conversation = await this.orchestrator.continueConversation(
conversationId,
prompt,
history, // Webview sends this with every continuation request
model,
aspectRatio
);
}All clients must provide:
interface TextClient {
getModel(): string;
createCompletion(messages: TextMessage[], options?: TextCompletionOptions): Promise<TextCompletionResult>;
isConfigured(): Promise<boolean>;
}
interface ImageGenerationClient {
generateImages(request: ImageGenerationRequest): Promise<ImageGenerationResult>;
isConfigured(): Promise<boolean>;
}- Text clients:
max_tokens: 48000,temperature: 0.7 - Image clients: Uses OpenRouter defaults per model
- SVG clients:
max_tokens: 48000,temperature: 0.7