| name | ext-panels |
| description | Building or modifying VS Code webview panels and multi-surface panel features. Use when creating new panels, adding features to existing panels, designing panel architecture across Extension/TUI/MCP/CLI. |
Webview Panel Development
The Rules
- All webview logic lives in TypeScript files under
src/panels/webview/, bundled by esbuild as IIFE for browser.
- All CSS lives in external
.css files under src/panels/styles/, bundled by esbuild.
- All messages are typed with discriminated unions in
src/panels/webview/shared/message-types.ts. Both host and webview switches use assertNever for exhaustive checking.
VS Code silently drops inline <script> tags exceeding ~32KB. External scripts have no size limit.
Panel Discovery
Do NOT rely on the listing below as the source of truth for which panels exist. Discover panels dynamically:
- Host panels:
glob src/PPDS.Extension/src/panels/*Panel.ts (excludes WebviewPanelBase)
- Webview entries:
glob src/PPDS.Extension/src/panels/webview/*-panel.ts
- Styles:
glob src/PPDS.Extension/src/panels/styles/*-panel.css
The listing below is a reference for architecture understanding, not an authoritative registry.
Architecture
src/panels/
QueryPanel.ts ← host-side panel (extends WebviewPanelBase<TIn, TOut>)
SolutionsPanel.ts ← host-side panel
ImportJobsPanel.ts ← host-side panel
ConnectionReferencesPanel.ts ← host-side panel
EnvironmentVariablesPanel.ts ← host-side panel
MetadataBrowserPanel.ts ← host-side panel
PluginTracesPanel.ts ← host-side panel
WebResourcesPanel.ts ← host-side panel
PluginsPanel.ts ← host-side panel (plugin registration)
WebviewPanelBase.ts ← abstract base: lifecycle, env picker, Maker URL, clipboard
environmentPicker.ts ← shared HTML generator + QuickPick helper
monacoUtils.ts ← detectLanguage, mapCompletionKind, mapCompletionItems
querySelectionUtils.ts ← getSelectionRect, isSingleCell, sanitizeValue, buildTsv
webviewUtils.ts ← shared webview helper utilities (getNonce)
monaco-entry.ts ← Monaco editor browser entry (IIFE bundle)
monaco-worker.ts ← Monaco editor worker entry
webview/ ← browser-side TypeScript (tsconfig.webview.json)
query-panel.ts ← Query Panel webview entry point
solutions-panel.ts ← Solutions Panel webview entry point
import-jobs-panel.ts ← Import Jobs Panel webview entry point
connection-references-panel.ts ← Connection References Panel webview entry point
environment-variables-panel.ts ← Environment Variables Panel webview entry point
metadata-browser-panel.ts ← Metadata Browser Panel webview entry point
plugin-traces-panel.ts ← Plugin Traces Panel webview entry point
web-resources-panel.ts ← Web Resources Panel webview entry point
plugins-panel.ts ← Plugin Registration Panel webview entry point
shared/
message-types.ts ← discriminated unions for ALL panel messages
dom-utils.ts ← escapeHtml, escapeAttr, cssEscape, formatDate, sanitizeValue
error-handler.ts ← centralized webview error handler (onerror, unhandledrejection)
filter-bar.ts ← generic FilterBar<T> — debounced text filtering with count
data-table.ts ← reusable DataTable<T> — sorting, selection, status formatting
solution-filter.ts ← SolutionFilter component — solution picker with persistence
selection-utils.ts ← getSelectionRect, isSingleCell, sanitizeValue, buildTsv
selection-manager.ts ← row/item selection state management
vscode-api.ts ← typed getVsCodeApi<T>() wrapper
assert-never.ts ← exhaustive switch helper
styles/ ← CSS files (esbuild bundles @import)
shared.css ← common styles (toolbar, status bar, spinner, env picker)
query-panel.css ← @import './shared.css' + Query Panel specific
solutions-panel.css ← @import './shared.css' + Solutions Panel specific
import-jobs-panel.css ← @import './shared.css' + Import Jobs specific
connection-references-panel.css ← @import './shared.css' + Connection References specific
environment-variables-panel.css ← @import './shared.css' + Environment Variables specific
metadata-browser-panel.css ← @import './shared.css' + Metadata Browser specific
plugin-traces-panel.css ← @import './shared.css' + Plugin Traces specific
web-resources-panel.css ← @import './shared.css' + Web Resources specific
plugins-panel.css ← @import './shared.css' + Plugin Registration specific
esbuild.js ← builds host + webview TS + CSS entry points
dist/
query-panel.js ← built webview bundle (IIFE)
query-panel.css ← built CSS bundle
solutions-panel.js / .css ← same pattern
monaco-editor.js / .css ← Monaco bundle
editor.worker.js ← Monaco worker
Creating a New Panel
1. Define message types
Add to src/panels/webview/shared/message-types.ts:
export type FooPanelWebviewToHost =
| { command: 'ready' }
| { command: 'refresh' }
| { command: 'someAction'; payload: string };
export type FooPanelHostToWebview =
| { command: 'dataLoaded'; items: SomeDto[] }
| { command: 'error'; message: string }
| { command: 'daemonReconnected' };
2. Create the host-side panel
Create src/panels/FooPanel.ts:
import type { FooPanelWebviewToHost, FooPanelHostToWebview } from './webview/shared/message-types.js';
import { assertNever } from './webview/shared/assert-never.js';
export class FooPanel extends WebviewPanelBase<FooPanelWebviewToHost, FooPanelHostToWebview> {
private setupMessageHandler(): void {
this.disposables.push(
this.panel!.webview.onDidReceiveMessage(
async (message: FooPanelWebviewToHost) => {
switch (message.command) {
case 'ready': await this.initialize(); break;
case 'refresh': await this.loadData(); break;
case 'someAction': await this.handleAction(message.payload); break;
default: assertNever(message);
}
}
)
);
}
getHtmlContent(webview: vscode.Webview): string {
const cssUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'dist', 'foo-panel.css'));
const jsUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'dist', 'foo-panel.js'));
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; font-src ${webview.cspSource};">
<link rel="stylesheet" href="${cssUri}">
</head>
<body>
<!-- HTML structure only — no inline CSS, no inline JS -->
<div class="toolbar">...</div>
<div class="content" id="content"></div>
<div class="status-bar"><span id="status-text">Ready</span></div>
<script nonce="${nonce}" src="${jsUri}"></script>
</body>
</html>`;
}
}
3. Create the webview script
Create src/panels/webview/foo-panel.ts:
import type { FooPanelWebviewToHost, FooPanelHostToWebview } from './shared/message-types.js';
import { escapeHtml } from './shared/dom-utils.js';
import { getVsCodeApi } from './shared/vscode-api.js';
import { assertNever } from './shared/assert-never.js';
const vscode = getVsCodeApi<FooPanelWebviewToHost>();
const content = document.getElementById('content')!;
const statusText = document.getElementById('status-text')!;
window.addEventListener('message', (event: MessageEvent<FooPanelHostToWebview>) => {
const msg = event.data;
switch (msg.command) {
case 'dataLoaded': renderData(msg.items); break;
case 'error': showError(msg.message); break;
case 'daemonReconnected': break;
default: assertNever(msg);
}
});
function renderData(items: SomeDto[]): void { }
function showError(message: string): void { }
vscode.postMessage({ command: 'ready' });
4. Create the CSS file
Create src/panels/styles/foo-panel.css:
@import './shared.css';
.content { flex: 1; overflow: auto; }
5. Add esbuild entry points
Add to esbuild.js:
const fooPanelCtx = await esbuild.context({
entryPoints: ['src/panels/webview/foo-panel.ts'],
bundle: true, format: 'iife', minify: production,
sourcemap: !production, sourcesContent: false,
platform: 'browser', outfile: 'dist/foo-panel.js', logLevel: 'warning',
});
const fooPanelCssCtx = await esbuild.context({
entryPoints: ['src/panels/styles/foo-panel.css'],
bundle: true, minify: production,
outfile: 'dist/foo-panel.css', logLevel: 'warning',
});
Add both to the watch(), rebuild(), and dispose() arrays.
6. Register in extension.ts and package.json
- Register the command handler in
src/extension.ts
- Add command contribution in
package.json
Naming Conventions
| File | Convention | Example |
|---|
| Host panel class | {Name}Panel.ts | PluginTracesPanel.ts |
| Webview entry | src/panels/webview/{name}-panel.ts | plugin-traces-panel.ts |
| Panel CSS | src/panels/styles/{name}-panel.css | plugin-traces-panel.css |
| Built JS output | dist/{name}-panel.js | dist/plugin-traces-panel.js |
| Built CSS output | dist/{name}-panel.css | dist/plugin-traces-panel.css |
What Goes Where
| Content | Location | Why |
|---|
| DOM manipulation, event handlers, message handling | webview/{name}-panel.ts | Browser context, typed |
| CSS styles | styles/{name}-panel.css | External, linted by Stylelint |
| HTML structure | getHtmlContent() template literal | Small, just structure + links |
| RPC calls, VS Code API, business logic | {Name}Panel.ts | Host context, Node.js |
| Message type definitions | webview/shared/message-types.ts | Shared between host and webview |
| Shared DOM utilities | webview/shared/dom-utils.ts | DRY across all panels |
| Shared HTML generators | environmentPicker.ts | Reused across panels |
| Debounced text filtering | webview/shared/filter-bar.ts | Generic, shared across panels |
| Monaco utilities (detect lang, map completions) | monacoUtils.ts | Pure functions, host-side |
| Selection/copy utilities | querySelectionUtils.ts | Pure functions, testable |
Daemon Communication
Host-side panels call the daemon via this.daemon (a DaemonClient instance passed at construction).
Calling RPC methods
const result = await this.daemon.querySql({
sql, top: 100, environmentUrl: this.environmentUrl
}, token);
Available methods are defined in daemonClient.ts. Common ones: querySql, queryFetch, queryExplain, queryExport, queryComplete, solutionsList, solutionsComponents, authWho, envList.
Handling daemon disconnection
WebviewPanelBase provides a reconnection hook. Subscribe in your constructor and override to auto-refresh:
this.subscribeToDaemonReconnect(daemon);
protected override onDaemonReconnected(): void {
void this.loadData();
}
The base class sends { command: 'daemonReconnected' } to the webview automatically — add it to your HostToWebview union type and handle it (e.g., show a refresh banner).
Query cancellation
For long-running operations, use CancellationTokenSource from vscode-jsonrpc:
private queryCts: CancellationTokenSource | undefined;
async executeQuery(): Promise<void> {
this.queryCts?.cancel();
this.queryCts = new CancellationTokenSource();
const token = this.queryCts.token;
const result = await this.daemon.querySql(params, token);
if (token.isCancellationRequested) return;
}
case 'cancelQuery': this.queryCts?.cancel(); break;
this.queryCts?.cancel();
this.queryCts?.dispose();
Panel-scoped environment
Panels can target a specific environment (not the global active one). Store environmentUrl as an instance property, pass it to every daemon call, and update it from the environment picker:
case 'selectEnvironment':
this.environmentUrl = message.url;
await this.loadData();
break;
Adding/Removing a Command
When you add a new message command:
- Add the variant to the union type in
message-types.ts
- The TypeScript compiler will error in BOTH the host switch and the webview switch (via
assertNever)
- Handle the new case in both switches
- No cast needed — the discriminated union gives you typed access to all fields
When you remove a command:
- Remove the variant from the union type
- The compiler will error wherever it's still referenced
- Remove all handling code
Exceptions
Quality Gates
Before committing panel changes:
npm run lint — ESLint (strict: no-floating-promises, no-explicit-any, etc.)
npm run lint:css — Stylelint for CSS files
npm run typecheck:all — both host and webview tsconfigs
npm run test — Vitest unit tests
- Visual verification (MANDATORY for UI changes): After any CSS, layout, HTML template, or message wiring change, use
/verify extension to take a screenshot and verify rendering. A passing typecheck is not proof of correct rendering. See /verify REFERENCE.md §ext for the verification protocol.
- Blind QA (recommended): For non-trivial features, run
/qa extension to dispatch a fresh agent that tests the panel without seeing source code.
Reference Implementations
- QueryPanel:
src/panels/QueryPanel.ts + src/panels/webview/query-panel.ts + src/panels/styles/query-panel.css
- SolutionsPanel:
src/panels/SolutionsPanel.ts + src/panels/webview/solutions-panel.ts + src/panels/styles/solutions-panel.css
- ImportJobsPanel:
src/panels/ImportJobsPanel.ts + src/panels/webview/import-jobs-panel.ts + src/panels/styles/import-jobs-panel.css — data table panel pattern (shared DataTable component, status badges, detail pane)
Design Guidance
Panel Anatomy
Every panel follows the same three-zone layout defined in shared.css:
┌─────────────────────────────────────────┐
│ Toolbar [env picker] [actions] [...] │ ← .toolbar (flex, 8px gap, border-bottom)
├─────────────────────────────────────────┤
│ │
│ Content area │ ← .content (flex: 1, overflow: auto)
│ (table / tree / detail) │
│ │
├─────────────────────────────────────────┤
│ Status bar: record count, timing, etc. │ ← .status-bar (border-top, 12px font)
└─────────────────────────────────────────┘
Rules:
- Toolbar always contains the environment picker (via
environmentPicker.ts)
- Content area gets
flex: 1 and handles its own scrolling
- Status bar shows contextual counts/timing — never empty, show "Ready" as default
- Empty state (
.empty-state), error state (.error-state), and loading state (.loading-state) are in shared.css — use them, don't reinvent
Reusable CSS Patterns
Before writing panel-specific CSS, check what already exists. Each pattern has a reference implementation — read it before building yours.
| Pattern | CSS Source | Reference Panel | Use When |
|---|
| Data table (sticky header, sort, selection) | query-panel.css .results-table | QueryPanel | Tabular data with columns |
| Tree/list (chevron expand, nested indent) | solutions-panel.css .solution-list | SolutionsPanel | Hierarchical browsing |
| Detail card (standalone, label/value grid) | solutions-panel.css .detail-card | SolutionsPanel | Inline record details |
| Detail card (nested, inside list items) | solutions-panel.css .component-detail-card | SolutionsPanel | Expandable item details |
| Filter bar (debounced input, count badge) | query-panel.css .filter-bar | QueryPanel | Filtering loaded results |
| Dropdown menu | query-panel.css .dropdown-menu | QueryPanel | Export, overflow actions |
| Context menu | query-panel.css .context-menu | QueryPanel | Right-click actions |
| Shared DataTable (sortable, status badges, row selection) | shared/data-table.ts DataTable<T> | ImportJobsPanel | All tabular data panels — shared component |
Rules:
@import './shared.css' as your first line — every panel gets toolbar, status bar, states for free
- Copy the CSS pattern from the reference, don't
@import panel-specific files into other panels
- Use
var(--vscode-*) tokens for all colors — never hardcode hex values
- Spacing: 4/6/8/12/16/40px scale (match existing panels, don't introduce new values)
- Font sizes: 11px (labels/badges), 12px (secondary text, detail cards), 13px (body/inputs)
- Border radius: 2px (inputs, badges), 4px (menus, dropdowns)
Environment Theming
Panels display two color accents based on the active environment:
1. Type-based top border (data-env-type attribute on .toolbar):
- Production → red, Sandbox → yellow, Development → green, Test → yellow, Trial → blue
- CSS rules in
shared.css using [data-env-type] selectors
- Maps to TUI's
StatusBar_Production/Sandbox/Development/Test/Trial color schemes
2. Color-based left border (data-env-color attribute on .toolbar):
- Uses the environment's configured hex color (from
environments.json)
- CSS rule:
[data-env-color] { border-left: 4px solid attr(data-env-color); } in shared.css
- Provides environment-specific branding beyond the type-based palette
Implementation: When the environment is selected (via picker or authWho on init), the host panel sends both envType and envColor in the updateEnvironment message. The webview script sets data-env-type and data-env-color on the .toolbar element. See QueryPanel and SolutionsPanel for reference.
Keyboard Shortcuts
All panels should support a standard set of keyboard shortcuts. Register in the webview script via document.addEventListener('keydown', ...). Check event.metaKey || event.ctrlKey for cross-platform support.
| Shortcut | Action | Notes |
|---|
Ctrl/Cmd+R | Refresh data | Re-fetch from daemon |
Ctrl/Cmd+F | Focus filter bar | If panel has filtering |
Escape | Close filter / deselect | Context-dependent |
Ctrl/Cmd+Shift+E | Export visible data | CSV or JSON, match query panel pattern |
Ctrl/Cmd+C | Copy selection | Tables: selected cells as TSV |
TUI Functional Parity
When designing an extension panel, verify the equivalent TUI screen exposes the same capabilities. This is a functional check, not a visual one — the interfaces look different but should offer equivalent data and actions.
Before marking a panel complete, confirm:
If the TUI screen doesn't exist yet, file or reference the corresponding wave:2 issue. Panels and screens can be built in parallel but should converge on the same RPC methods and data shapes.
Detail Pane Pattern
For panels that show detail when a row is selected (Import Jobs, Plugin Traces, Connection References), use the split layout:
- Table in
.content with flex: 1
- Detail pane below with
max-height: 40vh and overflow: auto
- Close button (x) and Escape key to dismiss
display: none when no row selected
- Reference: ImportJobsPanel's
.detail-pane in import-jobs-panel.css
Multi-Surface Panel Design
When designing a new panel or feature, design across all four PPDS surfaces: Daemon RPC, VS Code extension, TUI, and MCP.
Load Foundation
Read before doing anything:
specs/CONSTITUTION.md — A1, A2 govern multi-surface design (services are single code path, UI is thin wrapper)
specs/architecture.md — cross-cutting patterns
- Domain-relevant specs via frontmatter grep — search
specs/*.md for **Code:** lines matching src/PPDS.Extension/
Inventory Existing Infrastructure
For the target domain, check what exists at each layer:
| Layer | Location |
|---|
| Domain services | src/PPDS.Dataverse/Services/ (Dataverse data access) |
| Application services | src/PPDS.Cli/Services/ (orchestration, cross-cutting) |
| Entity classes | src/PPDS.Dataverse/Generated/Entities/ |
| CLI commands | src/PPDS.Cli/Commands/{Domain}/ |
| Daemon RPC methods | src/PPDS.Cli/Commands/Serve/Handlers/RpcMethodHandler.cs |
| MCP tools | src/PPDS.Mcp/Tools/ |
| TUI screens/dialogs | src/PPDS.Cli/Tui/Screens/, src/PPDS.Cli/Tui/Dialogs/ |
| VS Code panels | Extension src/panels/ |
Classify each as: Ready, Partial (needs extension), or Missing (must create).
Prerequisites before UI design:
- If the service is Missing, design it first — A1/A2 mandate services as the single code path.
- If required entity classes don't exist in
Generated/Entities/, entity generation is a prerequisite (Constitution I2 — never hand-edit generated entities).
- If the domain requires Power Platform APIs outside the Dataverse SDK (e.g., Connections API via
service.powerapps.com), identify those endpoints and any auth scope differences (IPowerPlatformTokenProvider).
Design RPC Endpoints and Data Contract
For each user-facing operation, define an RPC method:
- Method name:
{domain}/{operation} (e.g., importJobs/list, importJobs/get, pluginTraces/timeline)
- Request/Response DTOs with typed fields — these DTOs are the shared contract consumed by VS Code and MCP
- Error codes as domain-specific
PpdsException ErrorCodes (D4)
- Thread
CancellationToken through the entire async chain (R2)
- Accept
IProgressReporter for operations expected to take >1 second (A3)
RPC handler pattern: Methods are decorated with [JsonRpcMethod("domain/operation")] in RpcMethodHandler.cs. Handlers use IDaemonConnectionPoolManager for Dataverse access. Read existing methods for the pattern — no business logic in the handler, just parameter mapping, service call, DTO mapping.
Design TUI Screen
Reference existing screens in src/PPDS.Cli/Tui/Screens/ for patterns.
Define:
- Screen class name and menu/tab integration
- Layout (single table, split pane, tabbed)
- Hotkey bindings via
HotkeyRegistry
- Dialog inventory (detail views, filters, confirmations)
- Streaming vs batch data loading strategy
TUI screens call Application Services directly (no RPC indirection).
Design MCP Tools
Define tools for what AI agents need from this domain:
- Tool name:
ppds_{domain}_{operation}
- Input schema with parameter descriptions
- Output format structured for AI reasoning
Not every panel action needs an MCP tool. Focus on read/query operations. Skip UI-only actions (open in browser, keyboard shortcuts, export to clipboard).
Define Acceptance Criteria
For each surface, define "complete." Use legacy panel behavior as the floor:
- Data parity: Same fields/columns displayed as legacy
- Action parity: Same operations available (refresh, filter, export, open in Maker, etc.)
- UX parity: Same drill-down navigation, solution filtering, detail views
- Enhancements: Improvements enabled by new architecture (better data from existing services, environment theming, per-panel environment scoping)
Number all criteria: AC-01, AC-02, etc. (Constitution I3).
Map to Work Items
- Map design to existing GitHub issues
- Flag stale issues that need updating
- Identify gaps requiring new issues
- Recommend worktree/branch strategy based on complexity and dependencies
Multi-Surface Design Rules
- Services first, UI second — if the Application Service doesn't exist, design that before any UI surface.
- Reference existing panels — the first panel designed establishes patterns. Subsequent panels follow them.
- Legacy as floor, not ceiling — match what existed, improve where the new architecture enables it.
- Don't port code — understand what the legacy did, then design the proper abstraction. No inheriting legacy patterns.
- One panel at a time — complete design for one panel across all surfaces before starting the next.
- Cross-reference surface parity — VS Code panel and TUI screen must expose equivalent functionality (same data, same filters, same actions). MCP tools must cover the same read/query operations for AI agent access.
- Update skills after pattern-setting work — when the first panel in a batch establishes new patterns (e.g., virtual scrolling, complex state management), update this skill with the proven patterns before implementing subsequent panels.