فهرست منبع

chore(app): markdown playground in storyboard

Adam 3 هفته پیش
والد
کامیت
b480a38d31

+ 0 - 5
packages/ui/src/components/message-part.css

@@ -248,11 +248,6 @@
     opacity: 1;
     pointer-events: auto;
   }
-
-  [data-component="markdown"] {
-    margin-top: 0;
-    font-size: var(--font-size-base);
-  }
 }
 
 [data-component="compaction-part"] {

+ 0 - 4
packages/ui/src/components/session-turn.css

@@ -85,10 +85,6 @@
     flex-direction: column;
     align-self: stretch;
     gap: 12px;
-
-    > :first-child > [data-component="markdown"]:first-child {
-      margin-top: 0;
-    }
   }
 
   [data-slot="session-turn-diffs"] {

+ 1579 - 0
packages/ui/src/components/timeline-playground.stories.tsx

@@ -0,0 +1,1579 @@
+// @ts-nocheck
+import { createSignal, createMemo, For, Show, Index, batch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import type {
+  Message,
+  UserMessage,
+  AssistantMessage,
+  Part,
+  TextPart,
+  ReasoningPart,
+  ToolPart,
+  CompactionPart,
+  FilePart,
+  AgentPart,
+} from "@opencode-ai/sdk/v2"
+import { DataProvider } from "../context/data"
+import { FileComponentProvider } from "../context/file"
+import { SessionTurn } from "./session-turn"
+
+// ---------------------------------------------------------------------------
+// ID helpers
+// ---------------------------------------------------------------------------
+let seq = 0
+const uid = () => `pg-${++seq}-${Date.now().toString(36)}`
+
+// ---------------------------------------------------------------------------
+// Lorem ipsum content
+// ---------------------------------------------------------------------------
+const LOREM = [
+  "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 nisi ut aliquip ex ea commodo consequat.",
+  "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
+  "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+  "Cras justo odio, dapibus ut facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper.",
+]
+
+// ---------------------------------------------------------------------------
+// User message variants
+// ---------------------------------------------------------------------------
+const USER_VARIANTS = {
+  short: {
+    label: "short",
+    text: "Fix the bug in the login form",
+    parts: [] as Part[],
+  },
+  medium: {
+    label: "medium",
+    text: "Can you update the session timeline component to support lazy loading? The current implementation loads everything eagerly which causes jank on large sessions.",
+    parts: [] as Part[],
+  },
+  long: {
+    label: "long",
+    text: `I need you to refactor the message rendering pipeline. Currently the timeline renders all messages synchronously which blocks first paint. Here's what I want:
+
+1. Implement virtual scrolling for the message list
+2. Defer-mount older messages using requestAnimationFrame batching
+3. Add content-visibility: auto to each turn container
+4. Make sure the scroll-to-bottom behavior still works correctly after these changes
+
+Please also add appropriate CSS containment hints and make sure we don't break the sticky header behavior for the session title.`,
+    parts: [] as Part[],
+  },
+  "with @file": {
+    label: "with @file",
+    text: "Update @src/components/session-turn.tsx to fix the spacing issue between parts",
+    parts: (() => {
+      const id = `static-file-${Date.now()}`
+      return [
+        {
+          id,
+          type: "file",
+          mime: "text/plain",
+          filename: "session-turn.tsx",
+          url: "src/components/session-turn.tsx",
+          source: {
+            type: "file",
+            path: "src/components/session-turn.tsx",
+            text: {
+              value: "@src/components/session-turn.tsx",
+              start: 7,
+              end: 38,
+            },
+          },
+        } as FilePart,
+      ]
+    })(),
+  },
+  "with @agent": {
+    label: "with @agent",
+    text: "Use @explore to find all CSS files related to the timeline, then fix the spacing",
+    parts: (() => {
+      return [
+        {
+          id: `static-agent-${Date.now()}`,
+          type: "agent",
+          name: "explore",
+          source: { start: 4, end: 12 },
+        } as AgentPart,
+      ]
+    })(),
+  },
+  "with image": {
+    label: "with image",
+    text: "Here's a screenshot of the bug I'm seeing",
+    parts: (() => {
+      // 1x1 blue pixel PNG as data URI for a realistic attachment
+      const pixel =
+        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+      return [
+        {
+          id: `static-img-${Date.now()}`,
+          type: "file",
+          mime: "image/png",
+          filename: "screenshot.png",
+          url: pixel,
+        } as FilePart,
+      ]
+    })(),
+  },
+  "with file attachment": {
+    label: "with file attachment",
+    text: "Check this config file for issues",
+    parts: (() => {
+      return [
+        {
+          id: `static-attach-${Date.now()}`,
+          type: "file",
+          mime: "application/json",
+          filename: "tsconfig.json",
+          url: "data:application/json;base64,e30=",
+        } as FilePart,
+      ]
+    })(),
+  },
+  "multi attachment": {
+    label: "multi attachment",
+    text: "Look at these files and the screenshot, then fix the layout",
+    parts: (() => {
+      const pixel =
+        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+      return [
+        {
+          id: `static-multi-img-${Date.now()}`,
+          type: "file",
+          mime: "image/png",
+          filename: "layout-bug.png",
+          url: pixel,
+        } as FilePart,
+        {
+          id: `static-multi-file-${Date.now()}`,
+          type: "file",
+          mime: "text/css",
+          filename: "session-turn.css",
+          url: "data:text/css;base64,LyogZW1wdHkgKi8=",
+        } as FilePart,
+        {
+          id: `static-multi-ref-${Date.now()}`,
+          type: "file",
+          mime: "text/plain",
+          filename: "session-turn.tsx",
+          url: "src/components/session-turn.tsx",
+          source: {
+            type: "file",
+            path: "src/components/session-turn.tsx",
+            text: { value: "@src/components/session-turn.tsx", start: 0, end: 0 },
+          },
+        } as FilePart,
+      ]
+    })(),
+  },
+} satisfies Record<string, { label: string; text: string; parts: Part[] }>
+
+const MARKDOWN_SAMPLES = {
+  headings: `# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+
+Some paragraph text after headings.`,
+
+  lists: `Here's a list of changes:
+
+- First item with some explanation
+- Second item that is a bit longer and wraps to the next line when the viewport is narrow
+- Third item
+  - Nested item A
+  - Nested item B
+
+1. Numbered first
+2. Numbered second
+3. Numbered third`,
+
+  code: `Here's an inline \`variable\` reference and a code block:
+
+\`\`\`typescript
+export function sum(values: number[]) {
+  return values.reduce((total, value) => total + value, 0)
+}
+
+export function average(values: number[]) {
+  if (values.length === 0) return 0
+  return sum(values) / values.length
+}
+\`\`\`
+
+And some text after the code block.`,
+
+  mixed: `## Implementation Plan
+
+I'll make the following changes:
+
+1. **Update the schema** - Add new fields to the database model
+2. **Create the API endpoint** - Handle validation and persistence
+3. **Add frontend components** - Build the form and display views
+
+Here's the key change:
+
+\`\`\`typescript
+const table = sqliteTable("session", {
+  id: text().primaryKey(),
+  project_id: text().notNull(),
+  created_at: integer().notNull(),
+})
+\`\`\`
+
+> Note: This is a breaking change that requires a migration.
+
+The migration will handle existing data by setting \`project_id\` to the default workspace.
+
+---
+
+For more details, see the [documentation](https://example.com/docs).`,
+
+  table: `## Comparison
+
+| Feature | Before | After |
+|---------|--------|-------|
+| Speed | 120ms | 45ms |
+| Memory | 256MB | 128MB |
+| Bundle | 1.2MB | 890KB |
+
+The improvements are significant across all metrics.`,
+
+  blockquote: `## Summary
+
+> This is a blockquote that contains important information about the implementation approach.
+>
+> It spans multiple lines and contains **bold** and \`code\` elements.
+
+The approach above was chosen for its simplicity.`,
+
+  links: `Check out these resources:
+
+- [SolidJS docs](https://solidjs.com)
+- [TypeScript handbook](https://www.typescriptlang.org/docs/handbook)
+- The API is at \`https://api.example.com/v2\`
+
+You can also visit https://example.com/docs for more info.`,
+
+  images: `## Screenshot
+
+Here's what the output looks like:
+
+![Alt text](https://via.placeholder.com/400x200)
+
+And below is the final result.`,
+}
+
+const REASONING_SAMPLES = [
+  `**Analyzing the request**
+
+The user wants to add a new feature to the session timeline. I need to understand the existing component structure first.
+
+Let me look at the key files involved:
+- \`session-turn.tsx\` handles individual turns
+- \`message-part.tsx\` renders different part types
+- The data flows through the \`DataProvider\` context`,
+
+  `**Considering approaches**
+
+I could either modify the existing SessionTurn component or create a wrapper. The wrapper approach is cleaner because it doesn't touch the core rendering logic.
+
+The trade-off is that we'd need to pass additional props through, but that's acceptable for this use case.`,
+
+  `**Planning the implementation**
+
+I'll need to:
+1. Create the data generators
+2. Wire up the context providers
+3. Add CSS variable controls
+4. Implement the export functionality
+
+This should be straightforward given the existing component architecture.`,
+]
+
+const TOOL_SAMPLES = {
+  read: {
+    tool: "read",
+    input: { filePath: "src/components/session-turn.tsx", offset: 1, limit: 50 },
+    output: "export function SessionTurn(props) {\n  // component implementation\n  return <div>...</div>\n}",
+    title: "Read src/components/session-turn.tsx",
+    metadata: {},
+  },
+  glob: {
+    tool: "glob",
+    input: { pattern: "**/*.tsx", path: "src/components" },
+    output: "src/components/button.tsx\nsrc/components/card.tsx\nsrc/components/session-turn.tsx",
+    title: "Found 3 files",
+    metadata: {},
+  },
+  grep: {
+    tool: "grep",
+    input: { pattern: "SessionTurn", path: "src", include: "*.tsx" },
+    output: "src/components/session-turn.tsx:141\nsrc/pages/session/timeline.tsx:987",
+    title: "Found 2 matches",
+    metadata: {},
+  },
+  bash: {
+    tool: "bash",
+    input: { command: "bun test --filter session", description: "Run session tests" },
+    output:
+      "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests:       10 passed, 10 total\nTime:        0.89s",
+    title: "Run session tests",
+    metadata: { command: "bun test --filter session" },
+  },
+  edit: {
+    tool: "edit",
+    input: {
+      filePath: "src/components/session-turn.tsx",
+      oldString: "gap: 12px",
+      newString: "gap: 18px",
+    },
+    output: "File edited successfully",
+    title: "Edit src/components/session-turn.tsx",
+    metadata: {
+      filediff: {
+        file: "src/components/session-turn.tsx",
+        before: "  gap: 12px;\n  display: flex;",
+        after: "  gap: 18px;\n  display: flex;",
+        additions: 1,
+        deletions: 1,
+      },
+    },
+  },
+  write: {
+    tool: "write",
+    input: {
+      filePath: "src/utils/helpers.ts",
+      content:
+        "export function clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n",
+    },
+    output: "File written successfully",
+    title: "Write src/utils/helpers.ts",
+    metadata: {},
+  },
+  task: {
+    tool: "task",
+    input: { description: "Explore components", subagent_type: "explore", prompt: "Find all session components" },
+    output: "Found 12 session-related components across 3 directories.",
+    title: "Agent (Explore)",
+    metadata: { sessionId: "sub-session-1" },
+  },
+  webfetch: {
+    tool: "webfetch",
+    input: { url: "https://solidjs.com/docs/latest/api" },
+    output: "# SolidJS API Reference\n\nCore primitives for building reactive applications...",
+    title: "Fetch https://solidjs.com/docs/latest/api",
+    metadata: {},
+  },
+  websearch: {
+    tool: "websearch",
+    input: { query: "SolidJS createStore performance" },
+    output:
+      "https://solidjs.com/docs/latest/api#createstore\nhttps://dev.to/solidjs/understanding-solid-reactivity\nhttps://github.com/solidjs/solid/discussions/1234",
+    title: "Search: SolidJS createStore performance",
+    metadata: {},
+  },
+  question: {
+    tool: "question",
+    input: {
+      questions: [
+        {
+          question: "Which approach do you prefer?",
+          header: "Approach",
+          options: [
+            { label: "Wrapper component", description: "Create a new wrapper around SessionTurn" },
+            { label: "Direct modification", description: "Modify SessionTurn directly" },
+          ],
+        },
+      ],
+    },
+    output: "",
+    title: "Question",
+    metadata: { answers: [["Wrapper component"]] },
+  },
+  skill: {
+    tool: "skill",
+    input: { name: "playwriter" },
+    output: "Skill loaded successfully",
+    title: "playwriter",
+    metadata: {},
+  },
+  todowrite: {
+    tool: "todowrite",
+    input: {
+      todos: [
+        { content: "Create data generators", status: "completed", priority: "high" },
+        { content: "Build UI controls", status: "in_progress", priority: "high" },
+        { content: "Add CSS export", status: "pending", priority: "medium" },
+      ],
+    },
+    output: "",
+    title: "Todos",
+    metadata: {
+      todos: [
+        { content: "Create data generators", status: "completed", priority: "high" },
+        { content: "Build UI controls", status: "in_progress", priority: "high" },
+        { content: "Add CSS export", status: "pending", priority: "medium" },
+      ],
+    },
+  },
+}
+
+// ---------------------------------------------------------------------------
+// Fake data generators
+// ---------------------------------------------------------------------------
+const SESSION_ID = "playground-session"
+
+function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } {
+  const id = uid()
+  return {
+    message: {
+      id,
+      sessionID: SESSION_ID,
+      role: "user",
+      time: { created: Date.now() },
+      agent: "code",
+      model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+    } as UserMessage,
+    parts: [
+      { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart,
+      // Clone extra parts with fresh ids so each user message owns unique part instances
+      ...extra.map((p) => ({ ...p, id: uid() })),
+    ],
+  }
+}
+
+function mkAssistant(parentID: string): AssistantMessage {
+  return {
+    id: uid(),
+    sessionID: SESSION_ID,
+    role: "assistant",
+    time: { created: Date.now(), completed: Date.now() + 3000 },
+    parentID,
+    modelID: "claude-sonnet-4-20250514",
+    providerID: "anthropic",
+    mode: "default",
+    agent: "code",
+    path: { cwd: "/project", root: "/project" },
+    cost: 0.003,
+    tokens: { input: 1200, output: 800, reasoning: 200, cache: { read: 0, write: 0 } },
+  } as AssistantMessage
+}
+
+function textPart(text: string): TextPart {
+  return { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart
+}
+
+function reasoningPart(text: string): ReasoningPart {
+  return { id: uid(), type: "reasoning", text, time: { start: Date.now(), end: Date.now() + 500 } } as ReasoningPart
+}
+
+function toolPart(sample: (typeof TOOL_SAMPLES)[keyof typeof TOOL_SAMPLES], status = "completed"): ToolPart {
+  const base = {
+    id: uid(),
+    type: "tool" as const,
+    callID: uid(),
+    tool: sample.tool,
+  }
+  if (status === "completed") {
+    return {
+      ...base,
+      state: {
+        status: "completed",
+        input: sample.input,
+        output: sample.output,
+        title: sample.title,
+        metadata: sample.metadata ?? {},
+        time: { start: Date.now(), end: Date.now() + 1000 },
+      },
+    } as ToolPart
+  }
+  if (status === "running") {
+    return {
+      ...base,
+      state: {
+        status: "running",
+        input: sample.input,
+        title: sample.title,
+        metadata: sample.metadata ?? {},
+        time: { start: Date.now() },
+      },
+    } as ToolPart
+  }
+  return {
+    ...base,
+    state: { status: "pending", input: sample.input, raw: "" },
+  } as ToolPart
+}
+
+function compactionPart(): CompactionPart {
+  return { id: uid(), type: "compaction", auto: true } as CompactionPart
+}
+
+// ---------------------------------------------------------------------------
+// CSS Controls definition
+// ---------------------------------------------------------------------------
+type CSSControl = {
+  key: string
+  label: string
+  group: string
+  type: "range" | "color" | "select"
+  initial: string
+  selector: string
+  property: string
+  min?: string
+  max?: string
+  step?: string
+  options?: string[]
+  unit?: string
+}
+
+const CSS_CONTROLS: CSSControl[] = [
+  // --- Timeline spacing ---
+  {
+    key: "turn-gap",
+    label: "Turn gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "12",
+    selector: '[role="log"]',
+    property: "gap",
+    min: "0",
+    max: "80",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "container-gap",
+    label: "Container gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "18",
+    selector: '[data-slot="session-turn-message-container"]',
+    property: "gap",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "assistant-gap",
+    label: "Assistant parts gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "12",
+    selector: '[data-slot="session-turn-assistant-content"]',
+    property: "gap",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "text-part-margin",
+    label: "Text part margin-top",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="text-part"]',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown typography ---
+  {
+    key: "md-font-size",
+    label: "Font size",
+    group: "Markdown Typography",
+    type: "range",
+    initial: "14",
+    selector: '[data-component="markdown"]',
+    property: "font-size",
+    min: "10",
+    max: "22",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-line-height",
+    label: "Line height",
+    group: "Markdown Typography",
+    type: "range",
+    initial: "180",
+    selector: '[data-component="markdown"]',
+    property: "line-height",
+    min: "100",
+    max: "300",
+    step: "5",
+    unit: "%",
+  },
+
+  // --- Markdown headings ---
+  {
+    key: "md-heading-margin-top",
+    label: "Heading margin-top",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-heading-margin-bottom",
+    label: "Heading margin-bottom",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-heading-font-size",
+    label: "Heading font size",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "14",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "font-size",
+    min: "12",
+    max: "28",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown paragraphs ---
+  {
+    key: "md-p-margin-bottom",
+    label: "Paragraph margin-bottom",
+    group: "Markdown Paragraphs",
+    type: "range",
+    initial: "16",
+    selector: '[data-component="markdown"] p',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown lists ---
+  {
+    key: "md-list-margin-top",
+    label: "List margin-top",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "margin-top",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-list-margin-bottom",
+    label: "List margin-bottom",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "16",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-list-padding-left",
+    label: "List padding-left",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "padding-left",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-li-margin-bottom",
+    label: "List item margin-bottom",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] li',
+    property: "margin-bottom",
+    min: "0",
+    max: "20",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown code blocks ---
+  {
+    key: "md-pre-margin-top",
+    label: "Code block margin-top",
+    group: "Markdown Code",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] pre',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-pre-margin-bottom",
+    label: "Code block margin-bottom",
+    group: "Markdown Code",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] pre',
+    property: "margin-bottom",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-shiki-font-size",
+    label: "Code font size",
+    group: "Markdown Code",
+    type: "range",
+    initial: "13",
+    selector: '[data-component="markdown"] .shiki',
+    property: "font-size",
+    min: "10",
+    max: "20",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-shiki-padding",
+    label: "Code padding",
+    group: "Markdown Code",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] .shiki',
+    property: "padding",
+    min: "0",
+    max: "32",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-shiki-radius",
+    label: "Code border-radius",
+    group: "Markdown Code",
+    type: "range",
+    initial: "6",
+    selector: '[data-component="markdown"] .shiki',
+    property: "border-radius",
+    min: "0",
+    max: "16",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown blockquotes ---
+  {
+    key: "md-blockquote-margin",
+    label: "Blockquote margin",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] blockquote',
+    property: "margin-block",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-blockquote-padding-left",
+    label: "Blockquote padding-left",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] blockquote',
+    property: "padding-left",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-blockquote-border-width",
+    label: "Blockquote border width",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "2",
+    selector: '[data-component="markdown"] blockquote',
+    property: "border-left-width",
+    min: "0",
+    max: "8",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown tables ---
+  {
+    key: "md-table-margin",
+    label: "Table margin",
+    group: "Markdown Tables",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] table',
+    property: "margin-block",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "md-td-padding",
+    label: "Cell padding",
+    group: "Markdown Tables",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] :is(th,td)',
+    property: "padding",
+    min: "0",
+    max: "24",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Markdown HR ---
+  {
+    key: "md-hr-margin",
+    label: "HR margin",
+    group: "Markdown HR",
+    type: "range",
+    initial: "40",
+    selector: '[data-component="markdown"] hr',
+    property: "margin-block",
+    min: "0",
+    max: "80",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Reasoning part ---
+  {
+    key: "reasoning-md-margin-top",
+    label: "Reasoning markdown margin-top",
+    group: "Reasoning Part",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="reasoning-part"] [data-component="markdown"]',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- User message ---
+  {
+    key: "user-msg-padding",
+    label: "User bubble padding",
+    group: "User Message",
+    type: "range",
+    initial: "12",
+    selector: '[data-slot="user-message-text"]',
+    property: "padding",
+    min: "0",
+    max: "32",
+    step: "1",
+    unit: "px",
+  },
+  {
+    key: "user-msg-radius",
+    label: "User bubble border-radius",
+    group: "User Message",
+    type: "range",
+    initial: "6",
+    selector: '[data-slot="user-message-text"]',
+    property: "border-radius",
+    min: "0",
+    max: "24",
+    step: "1",
+    unit: "px",
+  },
+
+  // --- Tool parts ---
+  {
+    key: "bash-max-height",
+    label: "Shell output max-height",
+    group: "Tool Parts",
+    type: "range",
+    initial: "240",
+    selector: '[data-slot="bash-scroll"]',
+    property: "max-height",
+    min: "100",
+    max: "600",
+    step: "10",
+    unit: "px",
+  },
+]
+
+// ---------------------------------------------------------------------------
+// Playground component
+// ---------------------------------------------------------------------------
+function FileStub() {
+  return <div style={{ padding: "8px", color: "var(--text-weak)", "font-size": "13px" }}>File viewer stub</div>
+}
+
+function Playground() {
+  // ---- Messages & parts state ----
+  const [state, setState] = createStore<{
+    messages: Message[]
+    parts: Record<string, Part[]>
+  }>({
+    messages: [],
+    parts: {},
+  })
+
+  // ---- CSS overrides ----
+  const [css, setCss] = createStore<Record<string, string>>({})
+  let styleEl: HTMLStyleElement | undefined
+
+  const updateStyle = () => {
+    const rules: string[] = []
+    for (const ctrl of CSS_CONTROLS) {
+      const val = css[ctrl.key]
+      if (val === undefined) continue
+      const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+      rules.push(`${ctrl.selector} { ${ctrl.property}: ${value} !important; }`)
+    }
+    if (styleEl) styleEl.textContent = rules.join("\n")
+  }
+
+  const setCssValue = (key: string, value: string) => {
+    setCss(key, value)
+    updateStyle()
+  }
+
+  const resetCss = () => {
+    batch(() => {
+      for (const ctrl of CSS_CONTROLS) {
+        setCss(ctrl.key, undefined as any)
+      }
+    })
+    if (styleEl) styleEl.textContent = ""
+  }
+
+  // ---- Derived ----
+  const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
+
+  const data = createMemo(() => ({
+    session: [{ id: SESSION_ID }],
+    session_status: {},
+    session_diff: {},
+    message: { [SESSION_ID]: state.messages },
+    part: state.parts,
+    provider: {
+      all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
+    },
+  }))
+
+  // ---- Find or create the last assistant message to append parts to ----
+  const lastAssistantID = createMemo(() => {
+    for (let i = state.messages.length - 1; i >= 0; i--) {
+      if (state.messages[i].role === "assistant") return state.messages[i].id
+    }
+    return undefined
+  })
+
+  /** Ensure a turn (user + assistant) exists and return the assistant message id */
+  const ensureTurn = (): string => {
+    const id = lastAssistantID()
+    if (id) return id
+    // Create a minimal placeholder turn
+    const user = mkUser("...")
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = []
+      }),
+    )
+    return asst.id
+  }
+
+  /** Append parts to the last assistant message */
+  const appendParts = (parts: Part[]) => {
+    const id = ensureTurn()
+    setState(
+      produce((draft) => {
+        const existing = draft.parts[id] ?? []
+        draft.parts[id] = [...existing, ...parts]
+      }),
+    )
+  }
+
+  // ---- User message helpers ----
+  const addUser = (variant: keyof typeof USER_VARIANTS) => {
+    const v = USER_VARIANTS[variant]
+    const user = mkUser(v.text, v.parts)
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = []
+      }),
+    )
+  }
+
+  // ---- Part helpers (append to last turn) ----
+  const addText = (variant: keyof typeof MARKDOWN_SAMPLES) => {
+    appendParts([textPart(MARKDOWN_SAMPLES[variant])])
+  }
+
+  const addReasoning = () => {
+    const idx = Math.floor(Math.random() * REASONING_SAMPLES.length)
+    appendParts([reasoningPart(REASONING_SAMPLES[idx])])
+  }
+
+  const addTool = (name: keyof typeof TOOL_SAMPLES) => {
+    appendParts([toolPart(TOOL_SAMPLES[name])])
+  }
+
+  // ---- Composite helpers (create full turns with user + assistant) ----
+  const addFullTurn = (userText: string, parts: Part[]) => {
+    const user = mkUser(userText)
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = parts
+      }),
+    )
+  }
+
+  const addContextGroupTurn = () => {
+    addFullTurn("Read some files", [
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      textPart("After gathering context, here's what I found:\n\n" + LOREM[2]),
+    ])
+  }
+
+  const addReasoningFullTurn = () => {
+    addFullTurn("Make the changes described above", [
+      reasoningPart(REASONING_SAMPLES[0]),
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      toolPart(TOOL_SAMPLES.edit),
+      toolPart(TOOL_SAMPLES.bash),
+      textPart(MARKDOWN_SAMPLES.mixed),
+    ])
+  }
+
+  const addKitchenSink = () => {
+    // User message variants
+    addUser("short")
+    appendParts([textPart(MARKDOWN_SAMPLES.headings)])
+    addUser("medium")
+    appendParts([textPart(MARKDOWN_SAMPLES.lists)])
+    addUser("long")
+    appendParts([textPart(MARKDOWN_SAMPLES.code)])
+    addUser("with @file")
+    appendParts([textPart(MARKDOWN_SAMPLES.mixed)])
+    addUser("with image")
+    appendParts([reasoningPart(REASONING_SAMPLES[0]), textPart(MARKDOWN_SAMPLES.table)])
+    addUser("multi attachment")
+    appendParts([
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      toolPart(TOOL_SAMPLES.edit),
+      toolPart(TOOL_SAMPLES.bash),
+      textPart(MARKDOWN_SAMPLES.blockquote),
+    ])
+    addContextGroupTurn()
+    addReasoningFullTurn()
+  }
+
+  const clearAll = () => {
+    setState({ messages: [], parts: {} })
+    seq = 0
+  }
+
+  // ---- CSS export ----
+  const exportCss = () => {
+    const lines: string[] = ["/* Timeline Playground CSS Overrides */", ""]
+    const groups = new Map<string, string[]>()
+
+    for (const ctrl of CSS_CONTROLS) {
+      const val = css[ctrl.key]
+      if (val === undefined) continue
+      const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+      const group = ctrl.group
+      if (!groups.has(group)) groups.set(group, [])
+      groups.get(group)!.push(`/* ${ctrl.label}: ${value} */`)
+      groups.get(group)!.push(`${ctrl.selector} { ${ctrl.property}: ${value}; }`)
+    }
+
+    if (groups.size === 0) {
+      lines.push("/* No overrides applied */")
+    } else {
+      for (const [group, rules] of groups) {
+        lines.push(`/* --- ${group} --- */`)
+        lines.push(...rules)
+        lines.push("")
+      }
+    }
+
+    const text = lines.join("\n")
+    navigator.clipboard.writeText(text).catch(() => {})
+    return text
+  }
+
+  const [exported, setExported] = createSignal("")
+
+  // ---- Panel collapse state ----
+  const [panels, setPanels] = createStore({
+    generators: true,
+    css: true,
+    export: false,
+  })
+
+  // ---- Group collapse state for CSS ----
+  const [collapsed, setCollapsed] = createStore<Record<string, boolean>>({})
+  const groups = createMemo(() => {
+    const result = new Map<string, CSSControl[]>()
+    for (const ctrl of CSS_CONTROLS) {
+      if (!result.has(ctrl.group)) result.set(ctrl.group, [])
+      result.get(ctrl.group)!.push(ctrl)
+    }
+    return result
+  })
+
+  // ---- Shared button styles ----
+  const sectionLabel = {
+    "font-size": "11px",
+    color: "var(--text-weak)",
+    "margin-bottom": "4px",
+    "text-transform": "uppercase",
+    "letter-spacing": "0.5px",
+  } as const
+  const btnStyle = {
+    padding: "4px 8px",
+    "border-radius": "4px",
+    border: "1px solid var(--border-weak-base)",
+    background: "var(--surface-base)",
+    cursor: "pointer",
+    "font-size": "12px",
+    color: "var(--text-base)",
+  } as const
+  const btnAccent = {
+    ...btnStyle,
+    border: "1px solid var(--border-interactive-base)",
+    background: "var(--surface-interactive-weak)",
+    "font-weight": "500",
+    color: "var(--text-interactive-base)",
+  } as const
+  const btnDanger = {
+    ...btnStyle,
+    border: "1px solid var(--border-critical-base)",
+    background: "transparent",
+    color: "var(--text-on-critical-base)",
+  } as const
+
+  return (
+    <div style={{ display: "flex", height: "calc(100vh - 48px)", gap: "0", overflow: "hidden", margin: "-24px" }}>
+      {/* Inject dynamic style element */}
+      <style ref={styleEl!} />
+
+      {/* Left sidebar: controls */}
+      <div
+        style={{
+          width: "320px",
+          "min-width": "320px",
+          "border-right": "1px solid var(--border-weak-base)",
+          overflow: "auto",
+          "background-color": "var(--background-stronger)",
+          "scrollbar-width": "none",
+        }}
+      >
+        {/* Generate section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("generators", (v) => !v)}
+          >
+            Generate Messages
+            <span>{panels.generators ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.generators}>
+            <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
+              {/* ---- User messages ---- */}
+              <div style={sectionLabel}>User messages</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Creates a new turn (user + empty assistant)
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(USER_VARIANTS) as (keyof typeof USER_VARIANTS)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addUser(key)}>
+                      {USER_VARIANTS[key].label}
+                    </button>
+                  )}
+                </For>
+              </div>
+
+              {/* ---- Text and reasoning blocks ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Appends to the last turn's assistant parts
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(MARKDOWN_SAMPLES) as (keyof typeof MARKDOWN_SAMPLES)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addText(key)}>
+                      {key}
+                    </button>
+                  )}
+                </For>
+                <button style={btnStyle} onClick={addReasoning}>
+                  reasoning
+                </button>
+              </div>
+
+              {/* ---- Tool calls ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Tool calls</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Appends to the last turn's assistant parts
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(TOOL_SAMPLES) as (keyof typeof TOOL_SAMPLES)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addTool(key)}>
+                      {key}
+                    </button>
+                  )}
+                </For>
+              </div>
+
+              {/* ---- Composite (full turns) ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Composite turns</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Creates complete user + assistant turns
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <button style={btnStyle} onClick={addContextGroupTurn}>
+                  context group
+                </button>
+                <button style={btnStyle} onClick={addReasoningFullTurn}>
+                  full turn
+                </button>
+                <button style={btnAccent} onClick={addKitchenSink}>
+                  kitchen sink
+                </button>
+              </div>
+
+              <div style={{ "margin-top": "8px" }}>
+                <button style={btnDanger} onClick={clearAll}>
+                  Clear all
+                </button>
+              </div>
+            </div>
+          </Show>
+        </div>
+
+        {/* CSS Controls section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("css", (v) => !v)}
+          >
+            CSS Controls
+            <span>{panels.css ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.css}>
+            <div style={{ padding: "0 12px 12px" }}>
+              <button
+                style={{
+                  padding: "4px 8px",
+                  "border-radius": "4px",
+                  border: "1px solid var(--border-weak-base)",
+                  background: "var(--surface-base)",
+                  cursor: "pointer",
+                  "font-size": "11px",
+                  color: "var(--text-base)",
+                  "margin-bottom": "8px",
+                }}
+                onClick={resetCss}
+              >
+                Reset all
+              </button>
+
+              <For each={[...groups().entries()]}>
+                {([group, controls]) => (
+                  <div style={{ "margin-bottom": "4px" }}>
+                    <button
+                      style={{
+                        width: "100%",
+                        display: "flex",
+                        "align-items": "center",
+                        "justify-content": "space-between",
+                        padding: "6px 0",
+                        background: "none",
+                        border: "none",
+                        "border-bottom": "1px solid var(--border-weaker-base)",
+                        cursor: "pointer",
+                        "font-size": "11px",
+                        "font-weight": "500",
+                        color: "var(--text-base)",
+                        "text-transform": "uppercase",
+                        "letter-spacing": "0.5px",
+                      }}
+                      onClick={() => setCollapsed(group, (v) => !v)}
+                    >
+                      {group}
+                      <span style={{ "font-size": "10px" }}>{collapsed[group] ? "+" : "−"}</span>
+                    </button>
+                    <Show when={!collapsed[group]}>
+                      <div style={{ padding: "6px 0", display: "flex", "flex-direction": "column", gap: "8px" }}>
+                        <For each={controls}>
+                          {(ctrl) => (
+                            <div style={{ display: "flex", "flex-direction": "column", gap: "2px" }}>
+                              <div
+                                style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}
+                              >
+                                <label
+                                  style={{
+                                    "font-size": "11px",
+                                    color: "var(--text-base)",
+                                  }}
+                                >
+                                  {ctrl.label}
+                                </label>
+                                <span
+                                  style={{
+                                    "font-size": "11px",
+                                    color:
+                                      css[ctrl.key] !== undefined ? "var(--text-interactive-base)" : "var(--text-weak)",
+                                    "font-family": "var(--font-family-mono)",
+                                    "min-width": "40px",
+                                    "text-align": "right",
+                                  }}
+                                >
+                                  {css[ctrl.key] ?? ctrl.initial}
+                                  {ctrl.unit ?? ""}
+                                </span>
+                              </div>
+                              <input
+                                type="range"
+                                min={ctrl.min ?? "0"}
+                                max={ctrl.max ?? "100"}
+                                step={ctrl.step ?? "1"}
+                                value={css[ctrl.key] ?? ctrl.initial}
+                                onInput={(e) => setCssValue(ctrl.key, e.currentTarget.value)}
+                                style={{
+                                  width: "100%",
+                                  height: "4px",
+                                  "accent-color": "var(--text-interactive-base)",
+                                  cursor: "pointer",
+                                }}
+                              />
+                            </div>
+                          )}
+                        </For>
+                      </div>
+                    </Show>
+                  </div>
+                )}
+              </For>
+            </div>
+          </Show>
+        </div>
+
+        {/* Export section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("export", (v) => !v)}
+          >
+            Export CSS
+            <span>{panels.export ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.export}>
+            <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "8px" }}>
+              <button
+                style={{
+                  padding: "6px 12px",
+                  "border-radius": "4px",
+                  border: "1px solid var(--border-interactive-base)",
+                  background: "var(--surface-interactive-weak)",
+                  cursor: "pointer",
+                  "font-size": "12px",
+                  "font-weight": "500",
+                  color: "var(--text-interactive-base)",
+                }}
+                onClick={() => setExported(exportCss())}
+              >
+                Copy CSS to clipboard
+              </button>
+              <Show when={exported()}>
+                <pre
+                  style={{
+                    padding: "8px",
+                    "border-radius": "4px",
+                    background: "var(--surface-inset-base)",
+                    border: "1px solid var(--border-weak-base)",
+                    "font-size": "11px",
+                    "font-family": "var(--font-family-mono)",
+                    "line-height": "1.5",
+                    "white-space": "pre-wrap",
+                    "word-break": "break-all",
+                    "max-height": "300px",
+                    "overflow-y": "auto",
+                    color: "var(--text-base)",
+                  }}
+                >
+                  {exported()}
+                </pre>
+              </Show>
+            </div>
+          </Show>
+        </div>
+      </div>
+
+      {/* Main area: timeline preview */}
+      <div style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}>
+        <DataProvider data={data()} directory="/project">
+          <FileComponentProvider component={FileStub}>
+            <div
+              style={{
+                "max-width": "800px",
+                margin: "0 auto",
+                padding: "16px 0",
+              }}
+            >
+              <Show
+                when={userMessages().length > 0}
+                fallback={
+                  <div
+                    style={{
+                      display: "flex",
+                      "align-items": "center",
+                      "justify-content": "center",
+                      height: "400px",
+                      color: "var(--text-weak)",
+                      "font-size": "14px",
+                    }}
+                  >
+                    Click a generator button to add messages
+                  </div>
+                }
+              >
+                <div
+                  role="log"
+                  style={{ display: "flex", "flex-direction": "column", gap: "48px", width: "100%", padding: "0 20px" }}
+                >
+                  <For each={userMessages()}>
+                    {(msg) => (
+                      <div style={{ width: "100%" }}>
+                        <SessionTurn
+                          sessionID={SESSION_ID}
+                          messageID={msg.id}
+                          messages={state.messages}
+                          active={false}
+                          showReasoningSummaries={true}
+                          shellToolDefaultOpen={true}
+                          editToolDefaultOpen={true}
+                          classes={{
+                            root: "min-w-0 w-full relative",
+                            content: "flex flex-col justify-between !overflow-visible",
+                            container: "w-full",
+                          }}
+                        />
+                      </div>
+                    )}
+                  </For>
+                </div>
+              </Show>
+            </div>
+          </FileComponentProvider>
+        </DataProvider>
+      </div>
+    </div>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Story export
+// ---------------------------------------------------------------------------
+export default {
+  title: "Playground/Timeline",
+  id: "playground-timeline",
+  parameters: {
+    layout: "fullscreen",
+  },
+}
+
+export const Basic = {
+  render: () => <Playground />,
+}