diff --git a/apps/web/src/parts/markdown.tsx b/apps/web/src/parts/markdown.tsx index 116e377..3d1cf9d 100644 --- a/apps/web/src/parts/markdown.tsx +++ b/apps/web/src/parts/markdown.tsx @@ -101,29 +101,42 @@ function MarkdownImage(props: MarkdownImageProps) { type Highlighter = typeof import('./highlight.ts') let loading: Promise | null = null +/** The highlighter once it has loaded, so a block can highlight before it is first drawn. */ +let loaded: Highlighter | null = null /** Shiki and the theme, loaded the first time a code block renders. */ function loadHighlighter() { - if (!loading) loading = import('./highlight.ts') + if (!loading) + loading = import('./highlight.ts').then((module) => { + loaded = module + return module + }) return loading } /** * The code's tokens once its language's grammar loads, or null to show it plain. Code still - * streaming highlights as it grows. + * streaming highlights as it grows. Once Shiki and the grammar have loaded, as when a reply's + * record replaces its stream, a block is highlighted from its first frame rather than drawn plain + * and then again in color. */ function useHighlight(code: string, language: string, incomplete: boolean) { - const [result, setResult] = useState(null) + const [result, setResult] = useState(() => + loaded && language ? loaded.highlight(code, language, incomplete) : null, + ) useLayoutEffect(() => { if (!language) return let live = true const show = (next: TokensResult) => { if (live) setResult(next) } - void loadHighlighter().then((module) => { + const run = (module: Highlighter) => { const ready = module.highlight(code, language, incomplete, show) if (ready) show(ready) - }) + } + // Before the browser paints, when Shiki is here already. + if (loaded) run(loaded) + else void loadHighlighter().then(run) return () => { live = false } diff --git a/apps/web/tests/parts/markdown.test.tsx b/apps/web/tests/parts/markdown.test.tsx index 76a7382..c32053a 100644 --- a/apps/web/tests/parts/markdown.test.tsx +++ b/apps/web/tests/parts/markdown.test.tsx @@ -35,6 +35,18 @@ describe('Markdown', () => { expect(container.querySelector('.code-block')).toHaveTextContent('const a = 1') }) + it('colors code from its first render once its language has loaded', async () => { + const first = render() + await waitFor(() => + expect(first.container.querySelector('.code-block .line > span')).toBeTruthy(), + ) + first.unmount() + const { container } = render() + expect(container.querySelector('.code-block .line > span')).toHaveStyle({ + color: 'var(--color-code-token-keyword)', + }) + }) + it('shows code in a language it does not know as plain text', async () => { const { container } = render() await waitFor(() => diff --git a/specs/web-ui.md b/specs/web-ui.md index a19e69d..ee11392 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -82,7 +82,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - Instructions in the More menu opens a panel with a textarea holding the chat's own instructions, the `systemPrompt` of its `info` record, which the model follows in every reply in this chat after the user's custom instructions, as chat-turns says. Saving sends them trimmed through the conversation's `PATCH` route and closes the panel, and saving them empty clears them, which leaves `systemPrompt` out of the record, as when the chat was created. Saving them unchanged only closes the panel, so the chat doesn't move up the list. A failed save shows in the conversation's alert and leaves the panel open, and Escape or Cancel closes it. Every chat has an `info` record from its creation, so the menu item is disabled until the record has arrived from the local copy or the PDS, and the panel never starts empty over stored instructions. - **Message.** A message shows its work as glass, not a black box: what the model did is visible as it happens, collapsed to a line by default, and every step can be opened down to its exact input and output. - Its header names its author: "You", or the model that wrote the reply, from the record's `model`. - - Assistant text parts and reasoning are rendered as GitHub-flavored Markdown with Streamdown, as ADR 0005 explains. While a part streams, Streamdown completes its unfinished Markdown, so a `**` not yet closed already shows bold and a link still being typed shows as its text, and renders it block by block, so the blocks already written don't render again. Fenced code is highlighted by Shiki in the theme's colors, as it streams too, and a language Shiki doesn't know shows as plain text. Math between `$$` renders with KaTeX, loaded the first time a text holds some, and emphasis beside Chinese, Japanese, and Korean punctuation works. Raw HTML is not rendered and shows as the text it is, links open in a new tab, a fenced code block has buttons in its corner that copy its code and download it as a file named for its language, as `code.py`, once its fence closes, a table has menus in its corner that copy it as Markdown, CSV, or TSV and download it as CSV or Markdown, through Streamdown's table helpers with our own buttons and menu, and these show on hover and always on a touch screen, and images show their alt text, full URL, and a load button instead of loading, so injected content cannot leak the chat through an image URL without the user choosing to load it. User text is shown as plain text, except that runs of lines starting with `>`, as a quote from a reply sends them, show as quotes without their markers, in the same bubble as the text, through `splitQuotes` in `parts/quotes.ts`. Full Markdown for user text is planned. + - Assistant text parts and reasoning are rendered as GitHub-flavored Markdown with Streamdown, as ADR 0005 explains. While a part streams, Streamdown completes its unfinished Markdown, so a `**` not yet closed already shows bold and a link still being typed shows as its text, and renders it block by block, so the blocks already written don't render again. Fenced code is highlighted by Shiki in the theme's colors, as it streams too, and a language Shiki doesn't know shows as plain text. Once Shiki and a language's grammar have loaded, a block in that language is colored from its first frame, as when a reply's record replaces its stream, rather than drawn plain and then again in color. Math between `$$` renders with KaTeX, loaded the first time a text holds some, and emphasis beside Chinese, Japanese, and Korean punctuation works. Raw HTML is not rendered and shows as the text it is, links open in a new tab, a fenced code block has buttons in its corner that copy its code and download it as a file named for its language, as `code.py`, once its fence closes, a table has menus in its corner that copy it as Markdown, CSV, or TSV and download it as CSV or Markdown, through Streamdown's table helpers with our own buttons and menu, and these show on hover and always on a touch screen, and images show their alt text, full URL, and a load button instead of loading, so injected content cannot leak the chat through an image URL without the user choosing to load it. User text is shown as plain text, except that runs of lines starting with `>`, as a quote from a reply sends them, show as quotes without their markers, in the same bubble as the text, through `splitQuotes` in `parts/quotes.ts`. Full Markdown for user text is planned. - Parts show in the order the model produced them. Text, sources, images, and files show through `partViews` in `src/parts/index.ts`, and each run of reasoning and tool parts is one block of steps, drawn by `StepsView`, a `
` whose summary names the steps, such as "Thought · web_search ×2". A block of one step shows that step alone. A block shows only while the model is working in it, so once the reply's text starts streaming the work gives way to it. The block stays mounted but hidden, so its steps' times are still recorded. A finished reply keeps its steps behind a button at the end of its footer, named by their summary across every block, as "Thought for 12s · web_search", which opens them as one numbered list under the footer and keeps the footer shown while open. A reasoning step is a row like a tool step's, naming it, "Thought for 12s", or "Thinking" while it runs, with a muted glimpse of its first line of text, its heading, quote, and list marks and inline Markdown left out. It opens onto the whole reasoning as Markdown, and the glimpse goes while it is open. A tool call and its result, matched by `callId`, are one step whose summary shows the tool and a glimpse of its input, which goes while the step is open, and whose body shows the input and output pretty-printed when they are JSON, with a link to `outputBlob` when there is one. A failed tool or a call with no result is marked as such. A step renders its body only once it is first opened, and keeps it once closed, so a closed thought doesn't parse every token streaming into it and a closed tool step doesn't hold all the tool returned. - The footer's steps and each step start closed, and the reader opens what they want to see. - Sources are shown after the parts as a numbered list under their count, such as "3 sources", so a reader sees at a glance what the reply drew on. Each is a link named by its title, followed by its site, or named by its site alone when it has no title, with the full URL on hover. A streaming reply's sources join the list as they arrive. Past seven sources, the rest fold behind a "+3 more" badge at the end of the list, which shows them in place and becomes "Show less", while the count still says how many there are in all. A list that would hide only one source shows all eight instead. Opening it moves focus to the first link it showed, and the list starts folded each time it is drawn. The search results in a web search step fold the same way, through the same `useFold` in `src/parts/fold.ts`.