webui: convert SketchTimeline to use TailwindElement and Tailwind CSS classes
Convert SketchTimeline component from Lit CSS-in-JS styles to TailwindElement
inheritance with Tailwind utility classes, replacing shadow DOM styling with
global Tailwind CSS classes.
Problems Solved:
CSS Inconsistency:
- SketchTimeline used shadow DOM with CSS-in-JS styles while other components use TailwindElement
- Component styling was isolated from global design system
- Difficult to maintain consistent visual appearance across components
- No access to global Tailwind utility classes within shadow DOM
Test Brittleness:
- Tests relied on CSS class selectors that were implementation details
- Complex CSS class selectors made tests fragile to styling changes
- No standardized approach for testing UI elements across components
Missing Demo Infrastructure:
- SketchTimeline had no TypeScript demo module for component development
- Component not included in demo runner system for iterative development
- Only had static HTML demo without interactive controls
Solution Implementation:
TailwindElement Conversion:
- Changed inheritance from LitElement to SketchTailwindElement to disable shadow DOM
- Replaced all CSS-in-JS styles with equivalent Tailwind utility classes
- Added custom CSS for complex animations (thinking dots, loading spinner) that can't be easily replicated with Tailwind
- Maintained all existing visual styling and behavior while using Tailwind classes
CSS Class Mapping:
- .timeline-container → w-full relative max-w-full mx-auto px-[15px] box-border overflow-x-hidden flex-1 min-h-[100px]
- .welcome-box → my-8 mx-auto max-w-[90%] w-[90%] p-8 border-2 border-gray-300 rounded-lg shadow-sm bg-white text-center
- .thinking-indicator → pl-[85px] mt-1.5 mb-4 flex
- .loading-indicator → flex items-center justify-center p-5 text-gray-600 text-sm gap-2.5 opacity-100
- Added print: utility variants for print styling support
Test Infrastructure Updates:
- Replaced CSS class selectors with data-testid attributes for reliable element targeting
- Updated all test selectors to use [data-testid='element-name'] pattern
- Added test IDs to welcome-box, timeline-container, thinking-indicator, loading-indicator, thinking-bubble, thinking-dots, and thinking-dot elements
- Maintained all existing test functionality while improving test reliability
Demo Module Creation:
- Created sketch-timeline.demo.ts with comprehensive interactive demo
- Implemented basic timeline, loading states, thinking states, and interactive controls
- Added mock message generation with various message types and tool calls
- Included controls for adding messages, toggling thinking state, compact padding, and reset functionality
- Added SketchTimeline to knownComponents list in demo-runner.ts
Custom Styling Architecture:
- Added addCustomStyles() method to inject necessary CSS that can't be replicated with Tailwind
- Created thinking-pulse keyframe animation for thinking dots
- Added loading-spin animation for spinner elements
- Implemented compact-padding responsive styling
- Used document.head.appendChild for global style injection with duplicate prevention
Implementation Details:
Component Structure:
- Maintained all existing properties, methods, and component lifecycle
- Preserved scroll handling, viewport management, and loading operations
- Added data-testid attributes without affecting visual presentation
- Kept all existing functionality while changing only the styling approach
Styling Consistency:
- All colors, spacing, borders, and animations maintained visual parity
- Print styles converted to Tailwind print: variants
- Hover and active states preserved with Tailwind state variants
- Responsive design maintained with existing breakpoint behavior
Test Reliability:
- Test selectors now target semantic element roles rather than implementation details
- More robust element identification reduces test flakiness
- Consistent testing pattern across all timeline-related components
- Better separation between styling and testing concerns
Demo Development:
- Interactive demo supports real-time component behavior testing
- Mock data factory functions for consistent test data generation
- Multiple demo scenarios covering empty state, populated timeline, and various loading states
- Control buttons for testing user interactions and state changes
Files Modified:
- sketch/webui/src/web-components/sketch-timeline.ts: TailwindElement inheritance and Tailwind class conversion
- sketch/webui/src/web-components/sketch-timeline.test.ts: Updated test selectors to use data-testid attributes
- sketch/webui/src/web-components/demo/sketch-timeline.demo.ts: New interactive demo module
- sketch/webui/src/web-components/demo/demo-framework/demo-runner.ts: Added sketch-timeline to knownComponents
The conversion maintains complete visual and functional parity while enabling
consistent styling across the component library and improving test reliability
through semantic element targeting.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s0621383cac6304dek
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 396da39..a547c0a 100644
--- a/webui/src/web-components/demo/demo-framework/demo-runner.ts
+++ b/webui/src/web-components/demo/demo-framework/demo-runner.ts
@@ -99,6 +99,7 @@
"sketch-call-status",
"sketch-chat-input",
"sketch-container-status",
+ "sketch-timeline",
"sketch-tool-calls",
"sketch-view-mode-select",
];
diff --git a/webui/src/web-components/demo/sketch-timeline.demo.ts b/webui/src/web-components/demo/sketch-timeline.demo.ts
new file mode 100644
index 0000000..0a41250
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline.demo.ts
@@ -0,0 +1,302 @@
+/**
+ * Demo module for sketch-timeline component
+ */
+
+import { DemoModule } from "./demo-framework/types";
+import { demoUtils } from "./demo-fixtures/index";
+import type { AgentMessage } from "../../types";
+
+// Mock messages for demo
+function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
+ return {
+ idx: props.idx || 0,
+ type: props.type || "agent",
+ content: props.content || "Hello world",
+ timestamp: props.timestamp || "2023-05-15T12:00:00Z",
+ elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
+ end_of_turn: props.end_of_turn || false,
+ conversation_id: props.conversation_id || "conv123",
+ tool_calls: props.tool_calls || [],
+ commits: props.commits || undefined,
+ usage: props.usage,
+ hide_output: props.hide_output || false,
+ ...props,
+ };
+}
+
+function createMockMessages(count: number): AgentMessage[] {
+ return Array.from({ length: count }, (_, i) =>
+ createMockMessage({
+ idx: i,
+ content: `Message ${i + 1}: This is a sample message to demonstrate the timeline component.`,
+ type: i % 3 === 0 ? "user" : "agent",
+ timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
+ tool_calls:
+ i % 4 === 0
+ ? [
+ {
+ name: "bash",
+ input: `echo "Tool call example ${i}"`,
+ tool_call_id: `call_${i}`,
+ args: `{"command": "echo \"Tool call example ${i}\""}`,
+ result: `Tool call example ${i}`,
+ },
+ ]
+ : [],
+ usage:
+ i % 5 === 0
+ ? {
+ input_tokens: 10 + i,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: 50 + i * 2,
+ cost_usd: 0.001 * (i + 1),
+ }
+ : undefined,
+ }),
+ );
+}
+
+const demo: DemoModule = {
+ title: "Timeline Demo",
+ description:
+ "Interactive timeline component for displaying conversation messages with various states",
+ imports: ["../sketch-timeline"],
+ styles: ["/dist/tailwind.css"],
+
+ setup: async (container: HTMLElement) => {
+ // Create demo sections
+ const basicSection = demoUtils.createDemoSection(
+ "Basic Timeline",
+ "Timeline with a few sample messages",
+ );
+
+ const statesSection = demoUtils.createDemoSection(
+ "Timeline States",
+ "Different loading and thinking states",
+ );
+
+ const interactiveSection = demoUtils.createDemoSection(
+ "Interactive Demo",
+ "Add messages and control timeline behavior",
+ );
+
+ // Basic timeline with sample messages
+ const basicMessages = createMockMessages(5);
+ const basicTimeline = document.createElement("sketch-timeline") as any;
+ basicTimeline.messages = basicMessages;
+ basicTimeline.style.cssText =
+ "height: 400px; border: 1px solid #e1e5e9; border-radius: 6px; margin: 10px 0;";
+
+ // Create a scroll container for the basic timeline
+ const basicScrollContainer = document.createElement("div");
+ basicScrollContainer.style.cssText = "height: 400px; overflow-y: auto;";
+ basicScrollContainer.appendChild(basicTimeline);
+ basicTimeline.scrollContainer = { value: basicScrollContainer };
+
+ basicSection.appendChild(basicScrollContainer);
+
+ // Timeline with loading state
+ const loadingTimeline = document.createElement("sketch-timeline") as any;
+ loadingTimeline.messages = [];
+ loadingTimeline.isLoadingOlderMessages = false;
+ loadingTimeline.style.cssText =
+ "height: 200px; border: 1px solid #e1e5e9; border-radius: 6px; margin: 10px 0;";
+
+ const loadingWrapper = document.createElement("div");
+ loadingWrapper.style.cssText = "margin: 15px 0;";
+
+ const loadingLabel = document.createElement("h4");
+ loadingLabel.textContent = "Loading State (No messages)";
+ loadingLabel.style.cssText =
+ "margin: 0 0 10px 0; color: #24292f; font-size: 14px; font-weight: 600;";
+
+ loadingWrapper.appendChild(loadingLabel);
+ loadingWrapper.appendChild(loadingTimeline);
+ statesSection.appendChild(loadingWrapper);
+
+ // Timeline with thinking state
+ const thinkingMessages = createMockMessages(3);
+ const thinkingTimeline = document.createElement("sketch-timeline") as any;
+ thinkingTimeline.messages = thinkingMessages;
+ thinkingTimeline.llmCalls = 2;
+ thinkingTimeline.toolCalls = ["bash", "patch"];
+ thinkingTimeline.agentState = "thinking";
+ thinkingTimeline.style.cssText =
+ "height: 300px; border: 1px solid #e1e5e9; border-radius: 6px; margin: 10px 0;";
+
+ // Set initial load complete for thinking timeline
+ setTimeout(() => {
+ (thinkingTimeline as any).isInitialLoadComplete = true;
+ thinkingTimeline.requestUpdate();
+ }, 100);
+
+ const thinkingWrapper = document.createElement("div");
+ thinkingWrapper.style.cssText = "margin: 15px 0;";
+
+ const thinkingLabel = document.createElement("h4");
+ thinkingLabel.textContent = "Thinking State (Agent is active)";
+ thinkingLabel.style.cssText =
+ "margin: 0 0 10px 0; color: #24292f; font-size: 14px; font-weight: 600;";
+
+ const thinkingScrollContainer = document.createElement("div");
+ thinkingScrollContainer.style.cssText = "height: 300px; overflow-y: auto;";
+ thinkingScrollContainer.appendChild(thinkingTimeline);
+ thinkingTimeline.scrollContainer = { value: thinkingScrollContainer };
+
+ thinkingWrapper.appendChild(thinkingLabel);
+ thinkingWrapper.appendChild(thinkingScrollContainer);
+ statesSection.appendChild(thinkingWrapper);
+
+ // Interactive timeline
+ const interactiveMessages = createMockMessages(8);
+ const interactiveTimeline = document.createElement(
+ "sketch-timeline",
+ ) as any;
+ interactiveTimeline.messages = interactiveMessages;
+ interactiveTimeline.style.cssText =
+ "height: 400px; border: 1px solid #e1e5e9; border-radius: 6px; margin: 10px 0;";
+
+ // Set initial load complete for interactive timeline
+ setTimeout(() => {
+ (interactiveTimeline as any).isInitialLoadComplete = true;
+ interactiveTimeline.requestUpdate();
+ }, 100);
+
+ const interactiveScrollContainer = document.createElement("div");
+ interactiveScrollContainer.style.cssText =
+ "height: 400px; overflow-y: auto;";
+ interactiveScrollContainer.appendChild(interactiveTimeline);
+ interactiveTimeline.scrollContainer = { value: interactiveScrollContainer };
+
+ // Control buttons for interactive demo
+ const controlsDiv = document.createElement("div");
+ controlsDiv.style.cssText =
+ "margin-top: 20px; display: flex; flex-wrap: wrap; gap: 10px;";
+
+ const addMessageButton = demoUtils.createButton("Add User Message", () => {
+ const newMessage = createMockMessage({
+ idx: interactiveMessages.length,
+ content: `New user message added at ${new Date().toLocaleTimeString()}`,
+ type: "user",
+ timestamp: new Date().toISOString(),
+ });
+ interactiveMessages.push(newMessage);
+ interactiveTimeline.messages = [...interactiveMessages];
+ });
+
+ const addAgentMessageButton = demoUtils.createButton(
+ "Add Agent Message",
+ () => {
+ const newMessage = createMockMessage({
+ idx: interactiveMessages.length,
+ content: `New agent response added at ${new Date().toLocaleTimeString()}`,
+ type: "agent",
+ timestamp: new Date().toISOString(),
+ tool_calls:
+ Math.random() > 0.5
+ ? [
+ {
+ name: "bash",
+ input: "date",
+ tool_call_id: `call_${Date.now()}`,
+ args: '{"command": "date"}',
+ result: new Date().toString(),
+ },
+ ]
+ : [],
+ });
+ interactiveMessages.push(newMessage);
+ interactiveTimeline.messages = [...interactiveMessages];
+ },
+ );
+
+ const toggleThinkingButton = demoUtils.createButton(
+ "Toggle Thinking",
+ () => {
+ if (
+ interactiveTimeline.llmCalls > 0 ||
+ interactiveTimeline.toolCalls.length > 0
+ ) {
+ interactiveTimeline.llmCalls = 0;
+ interactiveTimeline.toolCalls = [];
+ } else {
+ interactiveTimeline.llmCalls = 1;
+ interactiveTimeline.toolCalls = ["bash"];
+ }
+ },
+ );
+
+ const toggleCompactButton = demoUtils.createButton(
+ "Toggle Compact Padding",
+ () => {
+ interactiveTimeline.compactPadding =
+ !interactiveTimeline.compactPadding;
+ },
+ );
+
+ const clearMessagesButton = demoUtils.createButton("Clear Messages", () => {
+ interactiveMessages.length = 0;
+ interactiveTimeline.messages = [];
+ });
+
+ const resetDemoButton = demoUtils.createButton("Reset Demo", () => {
+ interactiveMessages.length = 0;
+ interactiveMessages.push(...createMockMessages(8));
+ interactiveTimeline.messages = [...interactiveMessages];
+ interactiveTimeline.llmCalls = 0;
+ interactiveTimeline.toolCalls = [];
+ interactiveTimeline.compactPadding = false;
+ });
+
+ controlsDiv.appendChild(addMessageButton);
+ controlsDiv.appendChild(addAgentMessageButton);
+ controlsDiv.appendChild(toggleThinkingButton);
+ controlsDiv.appendChild(toggleCompactButton);
+ controlsDiv.appendChild(clearMessagesButton);
+ controlsDiv.appendChild(resetDemoButton);
+
+ const interactiveWrapper = document.createElement("div");
+ interactiveWrapper.style.cssText =
+ "padding: 10px; border: 1px solid #e1e5e9; border-radius: 6px; background: white;";
+ interactiveWrapper.appendChild(interactiveScrollContainer);
+ interactiveWrapper.appendChild(controlsDiv);
+ interactiveSection.appendChild(interactiveWrapper);
+
+ // Set initial load complete for basic timeline
+ setTimeout(() => {
+ (basicTimeline as any).isInitialLoadComplete = true;
+ basicTimeline.requestUpdate();
+ }, 100);
+
+ // Assemble the demo
+ container.appendChild(basicSection);
+ container.appendChild(statesSection);
+ container.appendChild(interactiveSection);
+
+ // Store references for cleanup
+ (container as any).timelines = [
+ basicTimeline,
+ loadingTimeline,
+ thinkingTimeline,
+ interactiveTimeline,
+ ];
+ },
+
+ cleanup: async () => {
+ // Clean up any timers or listeners if needed
+ const container = document.getElementById("demo-container");
+ if (container && (container as any).timelines) {
+ const timelines = (container as any).timelines;
+ timelines.forEach((timeline: any) => {
+ // Reset timeline state
+ timeline.llmCalls = 0;
+ timeline.toolCalls = [];
+ timeline.messages = [];
+ });
+ delete (container as any).timelines;
+ }
+ },
+};
+
+export default demo;
diff --git a/webui/src/web-components/demo/test-timeline-manual.html b/webui/src/web-components/demo/test-timeline-manual.html
new file mode 100644
index 0000000..67107cd
--- /dev/null
+++ b/webui/src/web-components/demo/test-timeline-manual.html
@@ -0,0 +1,188 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Sketch Timeline Manual Test</title>
+ <link rel="stylesheet" href="/dist/tailwind.css" />
+ <style>
+ body {
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ margin: 0;
+ padding: 20px;
+ }
+ .test-container {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ .test-section {
+ margin: 40px 0;
+ padding: 20px;
+ border: 1px solid #e1e5e9;
+ border-radius: 8px;
+ }
+ .test-section h3 {
+ margin: 0 0 15px 0;
+ color: #24292f;
+ }
+ .timeline-wrapper {
+ height: 400px;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ margin: 15px 0;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="test-container">
+ <h1>Sketch Timeline Component Test</h1>
+ <p>
+ This page tests the converted SketchTimeline component using Tailwind
+ CSS.
+ </p>
+
+ <div class="test-section">
+ <h3>Empty Timeline (Welcome State)</h3>
+ <div class="timeline-wrapper">
+ <sketch-timeline id="empty-timeline"></sketch-timeline>
+ </div>
+ </div>
+
+ <div class="test-section">
+ <h3>Timeline with Messages</h3>
+ <div class="timeline-wrapper">
+ <sketch-timeline id="messages-timeline"></sketch-timeline>
+ </div>
+ </div>
+
+ <div class="test-section">
+ <h3>Timeline with Thinking State</h3>
+ <div class="timeline-wrapper">
+ <sketch-timeline id="thinking-timeline"></sketch-timeline>
+ </div>
+ </div>
+
+ <div class="test-section">
+ <h3>Controls</h3>
+ <button id="add-message">Add Message</button>
+ <button id="toggle-thinking">Toggle Thinking</button>
+ <button id="clear-messages">Clear Messages</button>
+ </div>
+ </div>
+
+ <script type="module">
+ // Import the timeline component
+ import "../sketch-timeline.js";
+
+ // Mock messages
+ function createMockMessage(props = {}) {
+ return {
+ idx: props.idx || 0,
+ type: props.type || "agent",
+ content: props.content || "Hello world",
+ timestamp: props.timestamp || "2023-05-15T12:00:00Z",
+ elapsed: props.elapsed || 1500000000,
+ end_of_turn: props.end_of_turn || false,
+ conversation_id: props.conversation_id || "conv123",
+ tool_calls: props.tool_calls || [],
+ commits: props.commits || [],
+ usage: props.usage,
+ hide_output: props.hide_output || false,
+ ...props,
+ };
+ }
+
+ // Get timeline elements
+ const emptyTimeline = document.getElementById("empty-timeline");
+ const messagesTimeline = document.getElementById("messages-timeline");
+ const thinkingTimeline = document.getElementById("thinking-timeline");
+
+ // Set up messages timeline
+ const messages = [
+ createMockMessage({
+ idx: 0,
+ content: "Hello! I'm a user message.",
+ type: "user",
+ timestamp: "2023-05-15T12:00:00Z",
+ }),
+ createMockMessage({
+ idx: 1,
+ content: "And I'm an agent response with some details.",
+ type: "agent",
+ timestamp: "2023-05-15T12:01:00Z",
+ usage: {
+ input_tokens: 15,
+ output_tokens: 42,
+ cost_usd: 0.001234,
+ },
+ }),
+ createMockMessage({
+ idx: 2,
+ content: "Here's a message with tool calls.",
+ type: "agent",
+ timestamp: "2023-05-15T12:02:00Z",
+ tool_calls: [
+ {
+ name: "bash",
+ input: "echo 'Hello World'",
+ tool_call_id: "call_123",
+ args: '{"command": "echo \'Hello World\'"}',
+ result: "Hello World",
+ },
+ ],
+ }),
+ ];
+
+ messagesTimeline.messages = messages;
+ thinkingTimeline.messages = messages.slice(0, 2);
+
+ // Set initial load complete
+ setTimeout(() => {
+ messagesTimeline.isInitialLoadComplete = true;
+ thinkingTimeline.isInitialLoadComplete = true;
+ messagesTimeline.requestUpdate();
+ thinkingTimeline.requestUpdate();
+ }, 100);
+
+ // Set thinking state
+ thinkingTimeline.llmCalls = 1;
+ thinkingTimeline.toolCalls = ["bash"];
+
+ // Control buttons
+ let messageCount = messages.length;
+ document.getElementById("add-message").addEventListener("click", () => {
+ const newMessage = createMockMessage({
+ idx: messageCount++,
+ content: `New message added at ${new Date().toLocaleTimeString()}`,
+ type: messageCount % 2 === 0 ? "user" : "agent",
+ timestamp: new Date().toISOString(),
+ });
+ messages.push(newMessage);
+ messagesTimeline.messages = [...messages];
+ });
+
+ document
+ .getElementById("toggle-thinking")
+ .addEventListener("click", () => {
+ if (thinkingTimeline.llmCalls > 0) {
+ thinkingTimeline.llmCalls = 0;
+ thinkingTimeline.toolCalls = [];
+ } else {
+ thinkingTimeline.llmCalls = 1;
+ thinkingTimeline.toolCalls = ["bash"];
+ }
+ });
+
+ document
+ .getElementById("clear-messages")
+ .addEventListener("click", () => {
+ messages.length = 0;
+ messagesTimeline.messages = [];
+ messageCount = 0;
+ });
+ </script>
+ </body>
+</html>