diff --git a/packages/session-ui/src/components/markdown-solid.test.tsx b/packages/session-ui/src/components/markdown-solid.test.tsx index 2fd7b95c5a7..9e76f91a3d5 100644 --- a/packages/session-ui/src/components/markdown-solid.test.tsx +++ b/packages/session-ui/src/components/markdown-solid.test.tsx @@ -41,6 +41,32 @@ test("keeps completed block text compact", () => { ]) }) +test("marks streamed inline code for animation", () => { + expect(parseMarkdownNodes("

file.ts:29-43

", true, true)).toEqual([ + { + key: "0", + type: "element", + tag: "p", + attributes: {}, + children: [ + { + key: "0.0", + type: "element", + tag: "code", + attributes: {}, + children: Array.from("file.ts:29-43", (text, index) => ({ + key: `0.0.0:${index}`, + type: "word", + text, + animate: true, + })), + animate: true, + }, + ], + }, + ]) +}) + test("marks words for animation only when requested", () => { expect( parseMarkdownNodes("

Hello

", true).flatMap((node) => (node.type === "element" ? node.children : [node]))[0], diff --git a/packages/session-ui/src/components/markdown-solid.tsx b/packages/session-ui/src/components/markdown-solid.tsx index e302c0123b7..ed077684625 100644 --- a/packages/session-ui/src/components/markdown-solid.tsx +++ b/packages/session-ui/src/components/markdown-solid.tsx @@ -3,7 +3,14 @@ import { createStore, reconcile } from "solid-js/store" import { Dynamic, render } from "solid-js/web" type MarkdownNode = - | { key: string; type: "element"; tag: string; attributes: Record; children: MarkdownNode[] } + | { + key: string + type: "element" + tag: string + attributes: Record + children: MarkdownNode[] + animate?: true + } | { key: string; type: "text"; text: string } | { key: string; type: "word"; text: string; animate?: true } @@ -38,8 +45,12 @@ function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean }) ) } + let ref: HTMLElement | undefined + onMount(() => { + if (props.animate() && node.animate) ref?.setAttribute("data-markdown-enter", "") + }) return ( - + {(node) => } ) @@ -51,10 +62,10 @@ export function parseMarkdownNodes(html: string, words: boolean, animate = false return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words, animate)) } -function parseNode(node: Node, key: string, words: boolean, animate: boolean): MarkdownNode[] { +function parseNode(node: Node, key: string, words: boolean, animate: boolean, inlineCode = false): MarkdownNode[] { if (node instanceof Text) { if (!words) return [{ key, type: "text", text: node.data }] - return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => { + return (inlineCode ? Array.from(node.data) : node.data.split(/(\s+)/)).flatMap((text, index): MarkdownNode[] => { if (!text) return [] if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }] return [{ key: `${key}:${index}`, type: "word", text, ...(animate ? { animate: true as const } : {}) }] @@ -67,7 +78,18 @@ function parseNode(node: Node, key: string, words: boolean, animate: boolean): M type: "element", tag: node.tagName.toLowerCase(), attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])), - children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words, animate)), + children: Array.from(node.childNodes).flatMap((child, index) => + parseNode( + child, + `${key}.${index}`, + words, + animate, + inlineCode || (node.tagName === "CODE" && node.parentElement?.tagName !== "PRE"), + ), + ), + ...(words && animate && node.tagName === "CODE" && node.parentElement?.tagName !== "PRE" + ? { animate: true as const } + : {}), }, ] } diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 17a6b530e97..7b613ec3403 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -20,7 +20,7 @@ display: inline; } - [data-markdown-word][data-markdown-enter] { + :is([data-markdown-word], code)[data-markdown-enter] { animation: markdown-word-enter 180ms ease-out both; } @@ -347,7 +347,7 @@ } @media (prefers-reduced-motion: reduce) { - [data-component="markdown"] [data-markdown-word][data-markdown-enter] { + [data-component="markdown"] :is([data-markdown-word], code)[data-markdown-enter] { animation: none; } } diff --git a/packages/session-ui/src/components/markdown.stories.tsx b/packages/session-ui/src/components/markdown.stories.tsx index b1a175022d9..9ec7810765e 100644 --- a/packages/session-ui/src/components/markdown.stories.tsx +++ b/packages/session-ui/src/components/markdown.stories.tsx @@ -52,3 +52,36 @@ export const StreamingResponse = { ), } + +function StreamingInlineCodeMarkdown() { + const chunks = [ + "Updated ", + "`apps/cloud", + "flare/src/", + "editor/Cloud", + "Auth.ts:29", + "-43` and ", + "`packages/", + "session-ui/src/", + "components/markdown", + "-solid.tsx`, ", + "then verified ", + "the changes with ", + "`bun type", + "check` and ", + "`bun te", + "st`.", + ] + const [count, setCount] = createSignal(1) + const timer = setInterval(() => setCount((value) => (value >= chunks.length ? 1 : value + 1)), 220) + onCleanup(() => clearInterval(timer)) + return +} + +export const StreamingInlineCode = { + render: () => ( +
+ +
+ ), +}