Procházet zdrojové kódy

chore(app): markdown playground in storyboard

Adam před 3 týdny
rodič
revize
0c0c6f3bdb

+ 2 - 1
packages/app/src/pages/session/message-timeline.tsx

@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
             </Show>
             <div
               role="log"
-              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              data-slot="session-turn-list"
+              class="flex flex-col items-start justify-start pb-16 transition-[margin]"
               classList={{
                 "w-full": true,
                 "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,

+ 2 - 1
packages/storybook/.storybook/main.ts

@@ -2,6 +2,7 @@ import { defineMain } from "storybook-solidjs-vite"
 import path from "node:path"
 import { fileURLToPath } from "node:url"
 import tailwindcss from "@tailwindcss/vite"
+import { playgroundCss } from "./playground-css-plugin"
 
 const here = path.dirname(fileURLToPath(import.meta.url))
 const ui = path.resolve(here, "../../ui")
@@ -24,7 +25,7 @@ export default defineMain({
   async viteFinal(config) {
     const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
     return mergeConfig(config, {
-      plugins: [tailwindcss()],
+      plugins: [tailwindcss(), playgroundCss()],
       resolve: {
         dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
         alias: [

+ 136 - 0
packages/storybook/.storybook/playground-css-plugin.ts

@@ -0,0 +1,136 @@
+/**
+ * Vite plugin that exposes a POST endpoint for the timeline playground
+ * to write CSS changes back to source files on disk.
+ *
+ * POST /__playground/apply-css
+ * Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
+ *
+ * For each edit the plugin finds `anchor` in the file, then locates the
+ * next `prop: <anything>;` 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: <value>`
+    // 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<string, Edit[]>()
+          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 }))
+        })
+      })
+    },
+  }
+}

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

@@ -226,3 +226,7 @@
     display: none;
   }
 }
+
+[data-slot="session-turn-list"] {
+  gap: 48px;
+}

+ 209 - 17
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<Record<string, string>>({})
+  const [defaults, setDefaults] = createStore<Record<string, string>>({})
   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<string, string> = {}
+    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 ?? ""}
                                 </span>
                               </div>
@@ -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() {
           </button>
           <Show when={panels.export}>
             <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "8px" }}>
+              <button style={btnAccent} onClick={() => setExported(exportCss())}>
+                Copy CSS to clipboard
+              </button>
               <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)",
+                  ...btnAccent,
+                  opacity: changedControls().length === 0 || applying() ? "0.5" : "1",
+                  cursor: changedControls().length === 0 || applying() ? "not-allowed" : "pointer",
                 }}
-                onClick={() => setExported(exportCss())}
+                disabled={changedControls().length === 0 || applying()}
+                onClick={applyToSource}
               >
-                Copy CSS to clipboard
+                {applying()
+                  ? "Applying..."
+                  : `Apply ${changedControls().length} edit${changedControls().length === 1 ? "" : "s"} to source`}
               </button>
+              <Show when={changedControls().length > 0}>
+                <div
+                  style={{
+                    "font-size": "10px",
+                    color: "var(--text-weaker)",
+                    "line-height": "1.4",
+                  }}
+                >
+                  <For each={changedControls()}>
+                    {(ctrl) => (
+                      <div>
+                        {ctrl.source!.file}: {ctrl.property} = {css[ctrl.key]}
+                        {ctrl.unit}
+                      </div>
+                    )}
+                  </For>
+                </div>
+              </Show>
+              <Show when={applyResult()}>
+                <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": "200px",
+                    "overflow-y": "auto",
+                    color: "var(--text-base)",
+                  }}
+                >
+                  {applyResult()}
+                </pre>
+              </Show>
               <Show when={exported()}>
                 <pre
                   style={{
@@ -1502,7 +1690,10 @@ function Playground() {
       </div>
 
       {/* Main area: timeline preview */}
-      <div style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}>
+      <div
+        ref={previewRef!}
+        style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}
+      >
         <DataProvider data={data()} directory="/project">
           <FileComponentProvider component={FileStub}>
             <div
@@ -1531,7 +1722,8 @@ function Playground() {
               >
                 <div
                   role="log"
-                  style={{ display: "flex", "flex-direction": "column", gap: "48px", width: "100%", padding: "0 20px" }}
+                  data-slot="session-turn-list"
+                  style={{ display: "flex", "flex-direction": "column", width: "100%", padding: "0 20px" }}
                 >
                   <For each={userMessages()}>
                     {(msg) => (