From b480a38d313416f7020f61f8bfbe4df920fd90d4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:05:04 -0500 Subject: [PATCH] chore(app): markdown playground in storyboard --- packages/ui/src/components/message-part.css | 5 - packages/ui/src/components/session-turn.css | 4 - .../timeline-playground.stories.tsx | 1579 +++++++++++++++++ 3 files changed, 1579 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/timeline-playground.stories.tsx diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index aa685392a9..fbde8ee7cf 100644 --- a/packages/ui/src/components/message-part.css +++ b/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"] { diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 26d918050d..8075d25777 100644 --- a/packages/ui/src/components/session-turn.css +++ b/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"] { diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx new file mode 100644 index 0000000000..16bf2591a5 --- /dev/null +++ b/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 + +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
...
\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
File viewer stub
+} + +function Playground() { + // ---- Messages & parts state ---- + const [state, setState] = createStore<{ + messages: Message[] + parts: Record + }>({ + messages: [], + parts: {}, + }) + + // ---- CSS overrides ---- + const [css, setCss] = createStore>({}) + 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() + + 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>({}) + const groups = createMemo(() => { + const result = new Map() + 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 ( +
+ {/* Inject dynamic style element */} +