webui: clean up component demos
diff --git a/webui/src/web-components/demo/sketch-timeline-viewport.demo.ts b/webui/src/web-components/demo/sketch-timeline-viewport.demo.ts
new file mode 100644
index 0000000..0e53586
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline-viewport.demo.ts
@@ -0,0 +1,243 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { DemoModule } from "./demo-framework/types";
+import { demoUtils } from "./demo-fixtures/index";
+
+const demo: DemoModule = {
+ title: "Sketch Timeline Viewport Demo",
+ description:
+ "Timeline viewport rendering with memory leak protection and event-driven approach",
+ imports: ["../sketch-timeline.ts"],
+
+ customStyles: `
+ .demo-container {
+ max-width: 800px;
+ margin: 20px auto;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ height: 600px;
+ display: flex;
+ flex-direction: column;
+ }
+ .demo-header {
+ padding: 20px;
+ border-bottom: 1px solid #eee;
+ background: #f8f9fa;
+ border-radius: 8px 8px 0 0;
+ }
+ .demo-timeline {
+ flex: 1;
+ overflow: hidden;
+ }
+ .controls {
+ padding: 10px 20px;
+ border-top: 1px solid #eee;
+ background: #f8f9fa;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+ button {
+ padding: 8px 16px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ background: white;
+ cursor: pointer;
+ }
+ button:hover {
+ background: #f0f0f0;
+ }
+ .info {
+ font-size: 12px;
+ color: #666;
+ margin-left: auto;
+ }
+ `,
+
+ setup: async (container: HTMLElement) => {
+ const section = demoUtils.createDemoSection(
+ "Timeline Viewport Rendering",
+ "Demonstrates viewport-based rendering where only visible messages are rendered. Includes tests for memory leak fixes and race conditions.",
+ );
+
+ // Create demo container
+ const demoContainer = document.createElement("div");
+ demoContainer.className = "demo-container";
+
+ // Create header
+ const demoHeader = document.createElement("div");
+ demoHeader.className = "demo-header";
+ demoHeader.innerHTML = `
+ <h1>Sketch Timeline Viewport Rendering Demo</h1>
+ <p>
+ This demo shows how the timeline only renders messages in the
+ viewport. Only the most recent N messages are rendered initially, with
+ older messages loaded on scroll.
+ </p>
+ `;
+
+ // Create timeline container
+ const demoTimeline = document.createElement("div");
+ demoTimeline.className = "demo-timeline";
+
+ // Create the timeline component
+ const timeline = document.createElement("sketch-timeline") as any;
+ timeline.id = "timeline";
+ timeline.initialMessageCount = 20;
+ timeline.loadChunkSize = 10;
+
+ demoTimeline.appendChild(timeline);
+
+ // Create controls
+ const controls = document.createElement("div");
+ controls.className = "controls";
+
+ const info = document.createElement("span");
+ info.className = "info";
+ info.id = "info";
+ info.textContent = "Ready";
+
+ // Helper functions
+ const setupScrollContainer = () => {
+ if (timeline.shadowRoot) {
+ const scrollContainer =
+ timeline.shadowRoot.querySelector("#scroll-container");
+ if (scrollContainer) {
+ timeline.scrollContainer = { value: scrollContainer };
+ console.log("Scroll container set up:", scrollContainer);
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const waitForShadowDOM = () => {
+ if (setupScrollContainer()) {
+ return;
+ }
+
+ const observer = new MutationObserver(() => {
+ if (timeline.shadowRoot) {
+ observer.disconnect();
+ timeline.updateComplete.then(() => {
+ setupScrollContainer();
+ });
+ }
+ });
+
+ observer.observe(timeline, { childList: true, subtree: true });
+
+ timeline.updateComplete.then(() => {
+ if (!timeline.scrollContainer || !timeline.scrollContainer.value) {
+ setupScrollContainer();
+ }
+ });
+ };
+
+ const generateMessages = (count: number) => {
+ const messages = [];
+ for (let i = 0; i < count; i++) {
+ messages.push({
+ type: i % 3 === 0 ? "user" : "agent",
+ end_of_turn: true,
+ content: `Message ${i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
+ timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
+ conversation_id: "demo-conversation",
+ idx: i,
+ });
+ }
+
+ timeline.messages = messages;
+ timeline.resetViewport();
+
+ timeline.updateComplete.then(() => {
+ const showing = Math.min(count, timeline.initialMessageCount);
+ const expectedFirst = Math.max(1, count - showing + 1);
+ const expectedLast = count;
+ info.textContent = `${count} total messages, showing most recent ${showing} (messages ${expectedFirst}-${expectedLast})`;
+
+ if (!timeline.scrollContainer || !timeline.scrollContainer.value) {
+ setupScrollContainer();
+ }
+ });
+ };
+
+ // Create control buttons
+ const btn50 = demoUtils.createButton("50 Messages", () =>
+ generateMessages(50),
+ );
+ const btn100 = demoUtils.createButton("100 Messages", () =>
+ generateMessages(100),
+ );
+ const btn500 = demoUtils.createButton("500 Messages", () =>
+ generateMessages(500),
+ );
+ const btnClear = demoUtils.createButton("Clear", () => {
+ timeline.messages = [];
+ timeline.updateComplete.then(() => {
+ info.textContent = "Messages cleared";
+ });
+ });
+ const btnReset = demoUtils.createButton("Reset Viewport", () => {
+ timeline.resetViewport();
+ info.textContent = "Viewport reset to most recent messages";
+ });
+ const btnMemoryTest = demoUtils.createButton("Test Memory Leak Fix", () => {
+ let cleanupCount = 0;
+ const originalRemoveEventListener =
+ HTMLElement.prototype.removeEventListener;
+ HTMLElement.prototype.removeEventListener = function (
+ type: string,
+ listener: any,
+ ) {
+ if (type === "scroll") {
+ cleanupCount++;
+ console.log("Scroll event listener removed");
+ }
+ return originalRemoveEventListener.call(this, type, listener);
+ };
+
+ const mockContainer1 = document.createElement("div");
+ const mockContainer2 = document.createElement("div");
+
+ timeline.scrollContainer = { value: mockContainer1 };
+ timeline.scrollContainer = { value: mockContainer2 };
+ timeline.scrollContainer = { value: null };
+ timeline.scrollContainer = { value: mockContainer1 };
+
+ if (timeline.removeScrollListener) {
+ timeline.removeScrollListener();
+ }
+
+ HTMLElement.prototype.removeEventListener = originalRemoveEventListener;
+ info.textContent = `Memory leak fix test completed. Cleanup calls: ${cleanupCount}`;
+ });
+
+ controls.appendChild(btn50);
+ controls.appendChild(btn100);
+ controls.appendChild(btn500);
+ controls.appendChild(btnClear);
+ controls.appendChild(btnReset);
+ controls.appendChild(btnMemoryTest);
+ controls.appendChild(info);
+
+ // Assemble the demo
+ demoContainer.appendChild(demoHeader);
+ demoContainer.appendChild(demoTimeline);
+ demoContainer.appendChild(controls);
+
+ section.appendChild(demoContainer);
+ container.appendChild(section);
+
+ // Initialize
+ waitForShadowDOM();
+
+ // Generate initial messages after a brief delay
+ setTimeout(() => {
+ generateMessages(100);
+ }, 100);
+ },
+};
+
+export default demo;