webui: convert SketchCallStatus to Tailwind CSS with comprehensive demo support
Convert SketchCallStatus component from shadow DOM CSS to Tailwind classes
while maintaining test compatibility and adding complete demo infrastructure.
Problems Solved:
Shadow DOM Styling Limitations:
- SketchCallStatus used CSS-in-JS with shadow DOM preventing Tailwind integration
- Custom CSS animations and styling duplicated Tailwind functionality
- Component couldn't benefit from design system consistency
- Difficult to maintain custom CSS alongside Tailwind-based components
Missing Demo Infrastructure:
- No demo fixtures for testing SketchCallStatus component states
- Component not included in demo runner for development testing
- Manual testing required for visual verification of component behavior
Test Compatibility Issues:
- Conversion to Tailwind removed semantic class names expected by tests
- Need to maintain backward compatibility with existing test suite
Solution Implementation:
Tailwind CSS Conversion:
- Changed SketchCallStatus to inherit from SketchTailwindElement
- Replaced CSS-in-JS styles with Tailwind utility classes
- Converted animations using @keyframes in inline <style> tag
- Maintained exact visual appearance while using Tailwind classes
Component State Styling:
- LLM indicator: bg-yellow-100 text-amber-500 when active, text-gray-400 when idle
- Tool indicator: bg-blue-100 text-blue-500 when active, text-gray-400 when idle
- Status banner: bg-green-50 text-green-700 (idle), bg-orange-50 text-orange-600 (working), bg-red-50 text-red-600 (disconnected)
- Gentle pulse animation preserved with animate-gentle-pulse class
Test Compatibility Maintenance:
- Added semantic CSS classes back to elements (.llm-indicator, .tool-indicator, .status-banner)
- Added .active class when indicators are in active state
- Added status state classes (status-idle, status-working, status-disconnected)
- Maintains backward compatibility with existing Playwright tests
Demo Fixtures Implementation:
- Added call-status.ts with CallStatusState interface and sample states
- Created demo fixtures: idleCallStatus, workingCallStatus, heavyWorkingCallStatus, disconnectedCallStatus, workingDisconnectedCallStatus
- Fixed TypeScript module export issues using 'export type' syntax
- Comprehensive sketch-call-status.demo.ts with interactive controls
- Added component to demo-runner.ts knownComponents list
Interactive Demo Features:
- Status variations section showing all possible states
- Interactive demo with buttons to add/remove LLM calls and tool calls
- Toggle connection state and change agent state functionality
- Reset button to return to idle state
- Real-time simulation of activity changes
Files Modified:
- sketch/webui/src/web-components/sketch-call-status.ts: Converted to SketchTailwindElement with Tailwind classes and semantic class names
- sketch/webui/src/web-components/demo/demo-fixtures/call-status.ts: Added call status demo data
- sketch/webui/src/web-components/demo/demo-fixtures/index.ts: Export call status fixtures with proper TypeScript module exports
- sketch/webui/src/web-components/demo/sketch-call-status.demo.ts: Complete demo implementation with interactive controls
- sketch/webui/src/web-components/demo/demo-framework/demo-runner.ts: Added sketch-call-status to knownComponents
Testing and Validation:
- Verified component renders correctly with Tailwind classes
- Confirmed all state variations display proper colors and animations
- Tested interactive demo controls function correctly
- Validated component appears in demo runner list
- Ensured test compatibility with semantic class preservation
The conversion maintains visual fidelity and test compatibility while enabling
better integration with the Tailwind-based design system and providing
comprehensive demo infrastructure for development and testing.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s3437e5020555164dk
diff --git a/webui/src/web-components/demo/demo-fixtures/call-status.ts b/webui/src/web-components/demo/demo-fixtures/call-status.ts
new file mode 100644
index 0000000..b3c7bec
--- /dev/null
+++ b/webui/src/web-components/demo/demo-fixtures/call-status.ts
@@ -0,0 +1,51 @@
+/**
+ * Shared fake call status data for demos
+ */
+
+export interface CallStatusState {
+ llmCalls: number;
+ toolCalls: string[];
+ agentState: string | null;
+ isIdle: boolean;
+ isDisconnected: boolean;
+}
+
+export const idleCallStatus: CallStatusState = {
+ llmCalls: 0,
+ toolCalls: [],
+ agentState: null,
+ isIdle: true,
+ isDisconnected: false,
+};
+
+export const workingCallStatus: CallStatusState = {
+ llmCalls: 1,
+ toolCalls: ["patch", "bash"],
+ agentState: "analyzing code",
+ isIdle: false,
+ isDisconnected: false,
+};
+
+export const heavyWorkingCallStatus: CallStatusState = {
+ llmCalls: 3,
+ toolCalls: ["keyword_search", "patch", "bash", "think", "codereview"],
+ agentState: "refactoring components",
+ isIdle: false,
+ isDisconnected: false,
+};
+
+export const disconnectedCallStatus: CallStatusState = {
+ llmCalls: 0,
+ toolCalls: [],
+ agentState: null,
+ isIdle: true,
+ isDisconnected: true,
+};
+
+export const workingDisconnectedCallStatus: CallStatusState = {
+ llmCalls: 2,
+ toolCalls: ["browser_navigate", "patch"],
+ agentState: "testing changes",
+ isIdle: false,
+ isDisconnected: true,
+};
diff --git a/webui/src/web-components/demo/demo-fixtures/index.ts b/webui/src/web-components/demo/demo-fixtures/index.ts
index 627b257..0bfcc9d 100644
--- a/webui/src/web-components/demo/demo-fixtures/index.ts
+++ b/webui/src/web-components/demo/demo-fixtures/index.ts
@@ -32,6 +32,16 @@
createViewModeTestButtons,
} from "./view-mode-select";
+// Call status
+export {
+ idleCallStatus,
+ workingCallStatus,
+ heavyWorkingCallStatus,
+ disconnectedCallStatus,
+ workingDisconnectedCallStatus,
+} from "./call-status";
+export type { CallStatusState } from "./call-status";
+
// Common demo utilities
export const demoStyles = {
container: `
diff --git a/webui/src/web-components/demo/demo-framework/demo-runner.ts b/webui/src/web-components/demo/demo-framework/demo-runner.ts
index 76b7085..c624cb2 100644
--- a/webui/src/web-components/demo/demo-framework/demo-runner.ts
+++ b/webui/src/web-components/demo/demo-framework/demo-runner.ts
@@ -94,6 +94,7 @@
// For now, we'll maintain a registry of known demo components
// This could be improved with build-time generation
const knownComponents = [
+ "sketch-call-status",
"sketch-chat-input",
"sketch-container-status",
"sketch-tool-calls",
diff --git a/webui/src/web-components/demo/sketch-call-status.demo.ts b/webui/src/web-components/demo/sketch-call-status.demo.ts
new file mode 100644
index 0000000..44b8c21
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-call-status.demo.ts
@@ -0,0 +1,258 @@
+/**
+ * Demo module for sketch-call-status component
+ */
+
+import { DemoModule } from "./demo-framework/types";
+import {
+ demoUtils,
+ idleCallStatus,
+ workingCallStatus,
+ heavyWorkingCallStatus,
+ disconnectedCallStatus,
+ workingDisconnectedCallStatus,
+} from "./demo-fixtures/index";
+import type { CallStatusState } from "./demo-fixtures/index";
+
+const demo: DemoModule = {
+ title: "Call Status Demo",
+ description:
+ "Display current LLM and tool call status with visual indicators",
+ imports: ["../sketch-call-status"],
+ styles: ["/dist/tailwind.css"],
+
+ setup: async (container: HTMLElement) => {
+ // Create demo sections
+ const statusVariationsSection = demoUtils.createDemoSection(
+ "Status Variations",
+ "Different states of the call status component",
+ );
+
+ const interactiveSection = demoUtils.createDemoSection(
+ "Interactive Demo",
+ "Dynamically change call status to see real-time updates",
+ );
+
+ // Helper function to create status component with state
+ const createStatusComponent = (
+ id: string,
+ state: CallStatusState,
+ label: string,
+ ) => {
+ const wrapper = document.createElement("div");
+ wrapper.style.cssText =
+ "margin: 15px 0; padding: 10px; border: 1px solid #e1e5e9; border-radius: 6px; background: white;";
+
+ const labelEl = document.createElement("h4");
+ labelEl.textContent = label;
+ labelEl.style.cssText =
+ "margin: 0 0 10px 0; color: #24292f; font-size: 14px; font-weight: 600;";
+
+ const statusComponent = document.createElement(
+ "sketch-call-status",
+ ) as any;
+ statusComponent.id = id;
+ statusComponent.llmCalls = state.llmCalls;
+ statusComponent.toolCalls = state.toolCalls;
+ statusComponent.agentState = state.agentState;
+ statusComponent.isIdle = state.isIdle;
+ statusComponent.isDisconnected = state.isDisconnected;
+
+ wrapper.appendChild(labelEl);
+ wrapper.appendChild(statusComponent);
+ return wrapper;
+ };
+
+ // Create status variations
+ const idleStatus = createStatusComponent(
+ "idle-status",
+ idleCallStatus,
+ "Idle State - No active calls",
+ );
+
+ const workingStatus = createStatusComponent(
+ "working-status",
+ workingCallStatus,
+ "Working State - LLM and tool calls active",
+ );
+
+ const heavyWorkingStatus = createStatusComponent(
+ "heavy-working-status",
+ heavyWorkingCallStatus,
+ "Heavy Working State - Multiple calls active",
+ );
+
+ const disconnectedStatus = createStatusComponent(
+ "disconnected-status",
+ disconnectedCallStatus,
+ "Disconnected State - No connection",
+ );
+
+ const workingDisconnectedStatus = createStatusComponent(
+ "working-disconnected-status",
+ workingDisconnectedCallStatus,
+ "Working but Disconnected - Calls active but no connection",
+ );
+
+ // Interactive demo component
+ const interactiveStatus = document.createElement(
+ "sketch-call-status",
+ ) as any;
+ interactiveStatus.id = "interactive-status";
+ interactiveStatus.llmCalls = 0;
+ interactiveStatus.toolCalls = [];
+ interactiveStatus.agentState = null;
+ interactiveStatus.isIdle = true;
+ interactiveStatus.isDisconnected = false;
+
+ // Control buttons for interactive demo
+ const controlsDiv = document.createElement("div");
+ controlsDiv.style.cssText =
+ "margin-top: 20px; display: flex; flex-wrap: wrap; gap: 10px;";
+
+ const addLLMCallButton = demoUtils.createButton("Add LLM Call", () => {
+ interactiveStatus.llmCalls = interactiveStatus.llmCalls + 1;
+ interactiveStatus.isIdle = false;
+ });
+
+ const removeLLMCallButton = demoUtils.createButton(
+ "Remove LLM Call",
+ () => {
+ interactiveStatus.llmCalls = Math.max(
+ 0,
+ interactiveStatus.llmCalls - 1,
+ );
+ if (
+ interactiveStatus.llmCalls === 0 &&
+ interactiveStatus.toolCalls.length === 0
+ ) {
+ interactiveStatus.isIdle = true;
+ }
+ },
+ );
+
+ const addToolCallButton = demoUtils.createButton("Add Tool Call", () => {
+ const toolNames = [
+ "bash",
+ "patch",
+ "think",
+ "keyword_search",
+ "browser_navigate",
+ "codereview",
+ ];
+ const randomTool =
+ toolNames[Math.floor(Math.random() * toolNames.length)];
+ const currentTools = Array.isArray(interactiveStatus.toolCalls)
+ ? [...interactiveStatus.toolCalls]
+ : [];
+ if (!currentTools.includes(randomTool)) {
+ currentTools.push(randomTool);
+ interactiveStatus.toolCalls = currentTools;
+ interactiveStatus.isIdle = false;
+ }
+ });
+
+ const removeToolCallButton = demoUtils.createButton(
+ "Remove Tool Call",
+ () => {
+ const currentTools = Array.isArray(interactiveStatus.toolCalls)
+ ? [...interactiveStatus.toolCalls]
+ : [];
+ if (currentTools.length > 0) {
+ currentTools.pop();
+ interactiveStatus.toolCalls = currentTools;
+ if (interactiveStatus.llmCalls === 0 && currentTools.length === 0) {
+ interactiveStatus.isIdle = true;
+ }
+ }
+ },
+ );
+
+ const toggleConnectionButton = demoUtils.createButton(
+ "Toggle Connection",
+ () => {
+ interactiveStatus.isDisconnected = !interactiveStatus.isDisconnected;
+ },
+ );
+
+ const setAgentStateButton = demoUtils.createButton(
+ "Change Agent State",
+ () => {
+ const states = [
+ null,
+ "analyzing code",
+ "refactoring components",
+ "running tests",
+ "reviewing changes",
+ "generating documentation",
+ ];
+ const currentIndex = states.indexOf(interactiveStatus.agentState);
+ const nextIndex = (currentIndex + 1) % states.length;
+ interactiveStatus.agentState = states[nextIndex];
+ },
+ );
+
+ const resetButton = demoUtils.createButton("Reset to Idle", () => {
+ interactiveStatus.llmCalls = 0;
+ interactiveStatus.toolCalls = [];
+ interactiveStatus.agentState = null;
+ interactiveStatus.isIdle = true;
+ interactiveStatus.isDisconnected = false;
+ });
+
+ controlsDiv.appendChild(addLLMCallButton);
+ controlsDiv.appendChild(removeLLMCallButton);
+ controlsDiv.appendChild(addToolCallButton);
+ controlsDiv.appendChild(removeToolCallButton);
+ controlsDiv.appendChild(toggleConnectionButton);
+ controlsDiv.appendChild(setAgentStateButton);
+ controlsDiv.appendChild(resetButton);
+
+ // Assemble the demo
+ statusVariationsSection.appendChild(idleStatus);
+ statusVariationsSection.appendChild(workingStatus);
+ statusVariationsSection.appendChild(heavyWorkingStatus);
+ statusVariationsSection.appendChild(disconnectedStatus);
+ statusVariationsSection.appendChild(workingDisconnectedStatus);
+
+ const interactiveWrapper = document.createElement("div");
+ interactiveWrapper.style.cssText =
+ "padding: 10px; border: 1px solid #e1e5e9; border-radius: 6px; background: white;";
+ interactiveWrapper.appendChild(interactiveStatus);
+ interactiveWrapper.appendChild(controlsDiv);
+ interactiveSection.appendChild(interactiveWrapper);
+
+ container.appendChild(statusVariationsSection);
+ container.appendChild(interactiveSection);
+
+ // Add some simulation of real activity
+ const simulationInterval = setInterval(() => {
+ const statusComponents = [
+ document.getElementById("working-status") as any,
+ document.getElementById("heavy-working-status") as any,
+ ].filter(Boolean);
+
+ statusComponents.forEach((statusEl) => {
+ if (statusEl && Math.random() > 0.8) {
+ // 20% chance to update
+ // Simulate some activity by slightly changing the number of calls
+ const variation = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
+ statusEl.llmCalls = Math.max(0, statusEl.llmCalls + variation);
+ }
+ });
+ }, 2000);
+
+ // Store interval for cleanup
+ (container as any).demoInterval = simulationInterval;
+ },
+
+ cleanup: async () => {
+ // Clear any intervals
+ const container = document.getElementById("demo-container");
+ if (container && (container as any).demoInterval) {
+ clearInterval((container as any).demoInterval);
+ delete (container as any).demoInterval;
+ }
+ },
+};
+
+export default demo;