diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5fef41a550..33437ce9c9 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -896,7 +896,8 @@ export function MessageTimeline(props: {
} + * + * For each edit the plugin finds `anchor` in the file, then locates the + * next `prop: ;` after it and replaces the value portion. + * `file` is a basename resolved relative to packages/ui/src/components/. + */ +import type { Plugin } from "vite" +import type { IncomingMessage, ServerResponse } from "node:http" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(here, "../../ui/src/components") + +const ENDPOINT = "/__playground/apply-css" + +type Edit = { file: string; anchor: string; prop: string; value: string } +type Result = { file: string; prop: string; ok: boolean; error?: string } + +function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } { + const results: Result[] = [] + let out = content + + for (const edit of edits) { + const name = edit.file + const idx = out.indexOf(edit.anchor) + if (idx === -1) { + results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` }) + continue + } + + // From the anchor position, find the next occurrence of `prop: ` + // We match `prop:` followed by any value up to `;` + const after = out.slice(idx) + const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`) + const match = re.exec(after) + if (!match) { + results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` }) + continue + } + + const start = idx + match.index + match[1].length + const end = start + match[2].length + out = out.slice(0, start) + edit.value + out.slice(end) + results.push({ file: name, prop: edit.prop, ok: true }) + } + + return { content: out, results } +} + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export function playgroundCss(): Plugin { + return { + name: "playground-css", + configureServer(server) { + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (req.url !== ENDPOINT) return next() + if (req.method !== "POST") { + res.statusCode = 405 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Method not allowed" })) + return + } + + let data = "" + req.on("data", (chunk: Buffer) => { + data += chunk.toString() + }) + req.on("end", () => { + let payload: { edits: Edit[] } + try { + payload = JSON.parse(data) + } catch { + res.statusCode = 400 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Invalid JSON" })) + return + } + + if (!Array.isArray(payload.edits)) { + res.statusCode = 400 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ error: "Missing edits array" })) + return + } + + // Group by file + const grouped = new Map() + for (const edit of payload.edits) { + if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue + const abs = path.resolve(root, edit.file) + if (!abs.startsWith(root)) continue + const key = abs + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key)!.push(edit) + } + + const results: Result[] = [] + + for (const [abs, edits] of grouped) { + const name = path.basename(abs) + if (!fs.existsSync(abs)) { + for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" }) + continue + } + + try { + const content = fs.readFileSync(abs, "utf-8") + const applied = applyEdits(content, edits) + results.push(...applied.results) + + if (applied.results.some((r) => r.ok)) { + fs.writeFileSync(abs, applied.content, "utf-8") + } + } catch (err) { + for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) }) + } + } + + res.statusCode = 200 + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify({ results })) + }) + }) + }, + } +} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 8075d25777..c4a8726ac5 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -226,3 +226,7 @@ display: none; } } + +[data-slot="session-turn-list"] { + gap: 48px; +} diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 16bf2591a5..aa20ba940a 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { createSignal, createMemo, For, Show, Index, batch } from "solid-js" +import { createSignal, createMemo, createEffect, on, For, Show, Index, batch } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Message, @@ -515,6 +515,26 @@ function compactionPart(): CompactionPart { // --------------------------------------------------------------------------- // CSS Controls definition // --------------------------------------------------------------------------- + +// Source file basenames inside packages/ui/src/components/ +const MD = "markdown.css" +const MP = "message-part.css" +const ST = "session-turn.css" + +/** + * Source mapping for a CSS control. + * - `anchor`: immutable text near the property (comment, selector, etc.) that + * won't change when values change — used to locate the right rule block. + * - `prop`: the CSS property name whose value gets replaced. + * - `format`: turns the slider number into a CSS value string. + */ +type CSSSource = { + file: string + anchor: string + prop: string + format: (v: string) => string +} + type CSSControl = { key: string label: string @@ -528,8 +548,13 @@ type CSSControl = { step?: string options?: string[] unit?: string + source?: CSSSource } +const px = (v: string) => `${v}px` +const pxZero = (v: string) => `${v}px 0` +const pct = (v: string) => `${v}%` + const CSS_CONTROLS: CSSControl[] = [ // --- Timeline spacing --- { @@ -537,13 +562,14 @@ const CSS_CONTROLS: CSSControl[] = [ label: "Turn gap", group: "Timeline Spacing", type: "range", - initial: "12", - selector: '[role="log"]', + initial: "48", + selector: '[data-slot="session-turn-list"]', property: "gap", min: "0", max: "80", step: "1", unit: "px", + source: { file: ST, anchor: '[data-slot="session-turn-list"]', prop: "gap", format: px }, }, { key: "container-gap", @@ -557,6 +583,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: ST, anchor: '[data-slot="session-turn-message-container"]', prop: "gap", format: px }, }, { key: "assistant-gap", @@ -570,6 +597,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: ST, anchor: '[data-slot="session-turn-assistant-content"]', prop: "gap", format: px }, }, { key: "text-part-margin", @@ -583,6 +611,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MP, anchor: '[data-component="text-part"]', prop: "margin-top", format: px }, }, // --- Markdown typography --- @@ -598,6 +627,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "22", step: "1", unit: "px", + source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "font-size", format: px }, }, { key: "md-line-height", @@ -611,6 +641,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "300", step: "5", unit: "%", + source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "line-height", format: pct }, }, // --- Markdown headings --- @@ -626,6 +657,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "/* Headings:", prop: "margin-top", format: px }, }, { key: "md-heading-margin-bottom", @@ -639,6 +671,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: MD, anchor: "/* Headings:", prop: "margin-bottom", format: px }, }, { key: "md-heading-font-size", @@ -652,6 +685,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "28", step: "1", unit: "px", + source: { file: MD, anchor: "/* Headings:", prop: "font-size", format: px }, }, // --- Markdown paragraphs --- @@ -667,6 +701,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: MD, anchor: "/* Paragraphs */", prop: "margin-bottom", format: px }, }, // --- Markdown lists --- @@ -682,6 +717,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: MD, anchor: "/* Lists */", prop: "margin-top", format: px }, }, { key: "md-list-margin-bottom", @@ -695,6 +731,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: MD, anchor: "/* Lists */", prop: "margin-bottom", format: px }, }, { key: "md-list-padding-left", @@ -708,6 +745,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "/* Lists */", prop: "padding-left", format: px }, }, { key: "md-li-margin-bottom", @@ -721,6 +759,8 @@ const CSS_CONTROLS: CSSControl[] = [ max: "20", step: "1", unit: "px", + // Anchor on `li {` to skip the `ul,ol` margin-bottom above + source: { file: MD, anchor: "\n li {", prop: "margin-bottom", format: px }, }, // --- Markdown code blocks --- @@ -736,6 +776,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "\n pre {", prop: "margin-top", format: px }, }, { key: "md-pre-margin-bottom", @@ -749,6 +790,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "\n pre {", prop: "margin-bottom", format: px }, }, { key: "md-shiki-font-size", @@ -762,6 +804,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "20", step: "1", unit: "px", + source: { file: MD, anchor: ".shiki {", prop: "font-size", format: px }, }, { key: "md-shiki-padding", @@ -775,6 +818,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "32", step: "1", unit: "px", + source: { file: MD, anchor: ".shiki {", prop: "padding", format: px }, }, { key: "md-shiki-radius", @@ -788,6 +832,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "16", step: "1", unit: "px", + source: { file: MD, anchor: ".shiki {", prop: "border-radius", format: px }, }, // --- Markdown blockquotes --- @@ -803,6 +848,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "/* Blockquotes */", prop: "margin", format: pxZero }, }, { key: "md-blockquote-padding-left", @@ -816,6 +862,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "40", step: "1", unit: "px", + source: { file: MD, anchor: "/* Blockquotes */", prop: "padding-left", format: px }, }, { key: "md-blockquote-border-width", @@ -829,6 +876,12 @@ const CSS_CONTROLS: CSSControl[] = [ max: "8", step: "1", unit: "px", + source: { + file: MD, + anchor: "/* Blockquotes */", + prop: "border-left", + format: (v) => `${v}px solid var(--border-weak-base)`, + }, }, // --- Markdown tables --- @@ -844,6 +897,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MD, anchor: "/* Tables */", prop: "margin", format: pxZero }, }, { key: "md-td-padding", @@ -857,6 +911,8 @@ const CSS_CONTROLS: CSSControl[] = [ max: "24", step: "1", unit: "px", + // Anchor on td selector to skip other padding rules + source: { file: MD, anchor: "th,\n td {", prop: "padding", format: px }, }, // --- Markdown HR --- @@ -872,6 +928,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "80", step: "1", unit: "px", + source: { file: MD, anchor: "/* Horizontal Rule", prop: "margin", format: pxZero }, }, // --- Reasoning part --- @@ -887,6 +944,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "60", step: "1", unit: "px", + source: { file: MP, anchor: '[data-component="reasoning-part"]', prop: "margin-top", format: px }, }, // --- User message --- @@ -902,6 +960,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "32", step: "1", unit: "px", + source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "padding", format: px }, }, { key: "user-msg-radius", @@ -915,6 +974,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "24", step: "1", unit: "px", + source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "border-radius", format: px }, }, // --- Tool parts --- @@ -930,6 +990,7 @@ const CSS_CONTROLS: CSSControl[] = [ max: "600", step: "10", unit: "px", + source: { file: MP, anchor: '[data-slot="bash-scroll"]', prop: "max-height", format: px }, }, ] @@ -952,7 +1013,37 @@ function Playground() { // ---- CSS overrides ---- const [css, setCss] = createStore>({}) + const [defaults, setDefaults] = createStore>({}) let styleEl: HTMLStyleElement | undefined + let previewRef: HTMLDivElement | undefined + + /** Read computed styles from the DOM to seed slider defaults */ + const readDefaults = () => { + const root = previewRef + if (!root) return + const next: Record = {} + for (const ctrl of CSS_CONTROLS) { + const el = root.querySelector(ctrl.selector) as HTMLElement | null + if (!el) continue + const styles = getComputedStyle(el) + // Use bracket access — getPropertyValue doesn't resolve shorthands + const raw = (styles as any)[ctrl.property] as string + if (!raw) continue + // Shorthands may return "24px 0px" — take the first value + const num = parseFloat(raw.split(" ")[0]) + if (!Number.isFinite(num)) continue + // line-height returns px — convert back to % relative to font-size + if (ctrl.unit === "%") { + const fs = parseFloat(styles.fontSize) + if (fs > 0) { + next[ctrl.key] = String(Math.round((num / fs) * 100)) + continue + } + } + next[ctrl.key] = String(Math.round(num)) + } + setDefaults(next) + } const updateStyle = () => { const rules: string[] = [] @@ -993,6 +1084,18 @@ function Playground() { }, })) + // Read computed defaults once DOM has turn elements to query + createEffect( + on( + () => userMessages().length, + (len) => { + if (len === 0) return + // Wait a frame for the DOM to settle after render + requestAnimationFrame(readDefaults) + }, + ), + ) + // ---- Find or create the last assistant message to append parts to ---- const lastAssistantID = createMemo(() => { for (let i = state.messages.length - 1; i >= 0; i--) { @@ -1156,6 +1259,52 @@ function Playground() { const [exported, setExported] = createSignal("") + // ---- Apply to source files ---- + const [applying, setApplying] = createSignal(false) + const [applyResult, setApplyResult] = createSignal("") + + const changedControls = createMemo(() => CSS_CONTROLS.filter((ctrl) => css[ctrl.key] !== undefined && ctrl.source)) + + const applyToSource = async () => { + const controls = changedControls() + if (controls.length === 0) return + + setApplying(true) + setApplyResult("") + + const edits = controls.map((ctrl) => { + const src = ctrl.source! + return { file: src.file, anchor: src.anchor, prop: src.prop, value: src.format(css[ctrl.key]!) } + }) + + try { + const resp = await fetch("/__playground/apply-css", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ edits }), + }) + const data = await resp.json() + const ok = data.results?.filter((r: any) => r.ok).length ?? 0 + const fail = data.results?.filter((r: any) => !r.ok) ?? [] + const lines = [`Applied ${ok}/${edits.length} edits`] + for (const f of fail) { + lines.push(` FAIL ${f.file} ${f.prop}: ${f.error}`) + } + setApplyResult(lines.join("\n")) + + if (ok > 0) { + // Clear overrides — values are now in source CSS, Vite will HMR. + resetCss() + // Wait for Vite HMR then re-read computed defaults + setTimeout(readDefaults, 500) + } + } catch (err) { + setApplyResult(`Error: ${err}`) + } finally { + setApplying(false) + } + } + // ---- Panel collapse state ---- const [panels, setPanels] = createStore({ generators: true, @@ -1408,7 +1557,7 @@ function Playground() { "text-align": "right", }} > - {css[ctrl.key] ?? ctrl.initial} + {css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial} {ctrl.unit ?? ""}
@@ -1417,7 +1566,7 @@ function Playground() { min={ctrl.min ?? "0"} max={ctrl.max ?? "100"} step={ctrl.step ?? "1"} - value={css[ctrl.key] ?? ctrl.initial} + value={css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial} onInput={(e) => setCssValue(ctrl.key, e.currentTarget.value)} style={{ width: "100%", @@ -1461,21 +1610,60 @@ function Playground() {
- + + 0}> +
+ + {(ctrl) => ( +
+ {ctrl.source!.file}: {ctrl.property} = {css[ctrl.key]} + {ctrl.unit} +
+ )} +
+
+
+ +
+                  {applyResult()}
+                
+
 
       {/* Main area: timeline preview */}
-      
+
{(msg) => (