From 2dffd4e62553b1228c8cec94c8a74b9cb23d091f Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sat, 22 Aug 2026 10:16:11 +0000 Subject: [PATCH] feat(ui): render transcript footnotes as navigable endnotes Local markdown-it footnote plugin: superscript references jump to an ordered endnotes section and back to their exact reference. Note ids are namespaced per transcript message so identical notes stay distinct. No new dependencies. --- ui/src/components/markdown-footnotes.ts | 220 ++++++++++++++++++ ui/src/components/markdown-parser.ts | 2 + ui/src/components/markdown-render-options.ts | 20 +- ui/src/components/markdown.test.ts | 62 +++++ ui/src/components/markdown.ts | 10 +- ui/src/pages/chat/components/chat-divider.ts | 7 +- .../chat/components/chat-message-bubble.ts | 3 + .../chat/components/chat-message.test.ts | 4 + .../chat/components/chat-session-rail.ts | 6 +- .../chat/components/chat-sidebar-content.ts | 1 + ui/src/styles/chat/text.css | 46 +++- 11 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/markdown-footnotes.ts diff --git a/ui/src/components/markdown-footnotes.ts b/ui/src/components/markdown-footnotes.ts new file mode 100644 index 000000000000..5434a2235327 --- /dev/null +++ b/ui/src/components/markdown-footnotes.ts @@ -0,0 +1,220 @@ +// GFM-style footnote rendering (`[^label]` references + `[^label]: text` +// definitions) as a local markdown-it plugin: ordered endnotes, navigable +// superscript references, and per-note backlinks, with no external dependency. +import type MarkdownIt from "markdown-it"; +import type StateBlock from "markdown-it/lib/rules_block/state_block.mjs"; +import type StateCore from "markdown-it/lib/rules_core/state_core.mjs"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; +import type Token from "markdown-it/lib/token.mjs"; +import { t } from "../i18n/index.ts"; +import { escapeMarkdownHtml } from "./markdown-text.ts"; + +const FOOTNOTES_ENV_KEY = Symbol("markdownFootnotes"); + +type FootnoteRecord = { + label: string; + /** 1-based display number and anchor suffix, assigned by definition order. */ + n: number; + /** Reference occurrences; each gets its own backlink target id. */ + count: number; + text: string; +}; + +type FootnotesEnv = { + list: FootnoteRecord[]; + byLabel: Map; +}; + +type FootnoteItem = FootnoteRecord & { children: Token[] }; + +function footnotesIn(env: unknown): FootnotesEnv | undefined { + if (!env || typeof env !== "object") { + return undefined; + } + return (env as Record)[FOOTNOTES_ENV_KEY] as FootnotesEnv | undefined; +} + +function requireFootnotes(env: unknown): FootnotesEnv { + return ( + footnotesIn(env) ?? + ((env as Record)[FOOTNOTES_ENV_KEY] = { + list: [], + byLabel: new Map(), + }) + ); +} + +function footnoteDocId(env: unknown): string | undefined { + const docId: unknown = (env as { docId?: unknown } | undefined)?.docId; + return typeof docId === "string" && docId !== "" ? docId : undefined; +} + +/** Anchor namespace keeps identical notes in adjacent transcript messages from + * sharing DOM ids; the base36 hash is selector-safe so ids need no escaping. */ +function footnoteNamespace(env: unknown): string { + return `${footnoteDocId(env) ?? ""}-`; +} + +function footnoteNoteId(namespace: string, n: number): string { + return `fn${namespace}${n}`; +} + +function footnoteRefId(namespace: string, n: number, subId: number): string { + return `fnref${namespace}${n}${subId > 0 ? `-${subId}` : ""}`; +} + +function footnoteDefinitionRule( + state: StateBlock, + startLine: number, + endLine: number, + silent: boolean, +): boolean { + if ((state.sCount[startLine] ?? 0) - state.blkIndent >= 4) { + return false; + } + const start = (state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0); + const max = state.eMarks[startLine] ?? state.src.length; + // Shortest definition is "[^x]:". + if ( + start + 4 > max || + state.src.charCodeAt(start) !== 0x5b || + state.src.charCodeAt(start + 1) !== 0x5e + ) { + return false; + } + const close = state.src.indexOf("]", start + 2); + if (close < start + 3 || close > max - 2 || state.src.charCodeAt(close + 1) !== 0x3a) { + return false; + } + const label = state.src.slice(start + 2, close); + // Labels stay on one line without whitespace or nested brackets; anything + // else is prose (or a link reference), not a footnote. + if (/[\s[]/.test(label)) { + return false; + } + if (silent) { + return true; + } + + // Note body: this line's remainder plus continuation lines indented by at + // least four columns; a blank or unindented line ends the note. + let cursor = close + 2; + while (cursor < max && /\s/.test(state.src.charAt(cursor))) { + cursor += 1; + } + const lines = [state.src.slice(cursor, max)]; + let line = startLine + 1; + while (line < endLine) { + const contentStart = (state.bMarks[line] ?? 0) + (state.tShift[line] ?? 0); + const contentEnd = state.eMarks[line] ?? contentStart; + if (contentStart === contentEnd || (state.sCount[line] ?? 0) < 4) { + break; + } + lines.push(state.src.slice(contentStart, contentEnd)); + line += 1; + } + + const footnotes = requireFootnotes(state.env); + const key = label.toLowerCase(); + // Duplicate labels merge into the first note, matching reference semantics. + if (!footnotes.byLabel.has(key)) { + const record: FootnoteRecord = { + label, + n: footnotes.list.length + 1, + count: 0, + text: lines.join("\n").trim(), + }; + footnotes.list.push(record); + footnotes.byLabel.set(key, record); + } + state.line = line; + return true; +} + +function footnoteReferenceRule(state: StateInline, silent: boolean): boolean { + const pos = state.pos; + const src = state.src; + if (src.charCodeAt(pos) !== 0x5b || src.charCodeAt(pos + 1) !== 0x5e) { + return false; + } + const close = src.indexOf("]", pos + 2); + if (close < pos + 3) { + return false; + } + const label = src.slice(pos + 2, close); + if (/[\s[]/.test(label)) { + return false; + } + const record = footnotesIn(state.env)?.byLabel.get(label.toLowerCase()); + // Undefined labels keep their literal source text instead of a dead anchor. + if (!record) { + return false; + } + if (!silent) { + const subId = record.count; + record.count += 1; + const token = state.push("footnote_ref", "", 0); + token.meta = { n: record.n, subId }; + } + state.pos = close + 1; + return true; +} + +function footnotesTailRule(state: StateCore): void { + const footnotes = footnotesIn(state.env); + if (!footnotes || footnotes.list.length === 0) { + return; + } + const items: FootnoteItem[] = footnotes.list.map((record): FootnoteItem => { + const children: Token[] = []; + state.md.inline.parse(record.text, state.md, state.env, children); + return { + label: record.label, + n: record.n, + count: record.count, + text: record.text, + children, + }; + }); + const token = new state.Token("footnotes_block", "", 0); + token.block = true; + token.meta = { items }; + state.tokens.push(token); +} + +export function installMarkdownFootnotes(markdownParser: MarkdownIt): void { + markdownParser.block.ruler.before("reference", "footnote_definition", footnoteDefinitionRule, { + alt: ["paragraph", "reference", "blockquote", "list"], + }); + markdownParser.inline.ruler.before("link", "footnote_ref", footnoteReferenceRule); + // Definitions may appear after their references, so the endnotes section can + // only be built once the whole document has been tokenized. + markdownParser.core.ruler.push("footnotes_tail", footnotesTailRule); + + markdownParser.renderer.rules.footnote_ref = (tokens, index, _options, env) => { + const meta = tokens[index]?.meta as { n?: unknown; subId?: unknown } | undefined; + const n = Number(meta?.n ?? 0); + const subId = Number(meta?.subId ?? 0); + const namespace = footnoteNamespace(env); + return `${n}`; + }; + + markdownParser.renderer.rules.footnotes_block = (tokens, index, options, env, self) => { + const items = (tokens[index]?.meta as { items?: FootnoteItem[] } | undefined)?.items ?? []; + if (items.length === 0) { + return ""; + } + const namespace = footnoteNamespace(env); + const backLink = (n: number, subId: number) => + ``; + const listItems = items + .map((item) => { + const backlinks = Array.from({ length: Math.max(item.count, 1) }, (_, subId) => + backLink(item.n, subId), + ).join(""); + return `
  • ${self.renderInline(item.children, options, env)}${backlinks}

  • `; + }) + .join("\n"); + return `
    \n
    \n
      \n${listItems}\n
    \n
    \n`; + }; +} diff --git a/ui/src/components/markdown-parser.ts b/ui/src/components/markdown-parser.ts index bde8bfbcff4e..6e1940d23664 100644 --- a/ui/src/components/markdown-parser.ts +++ b/ui/src/components/markdown-parser.ts @@ -16,6 +16,7 @@ import { parseMarkdownFileLinkTarget, splitMarkdownFileLineSuffix, } from "./markdown-file-links.ts"; +import { installMarkdownFootnotes } from "./markdown-footnotes.ts"; import type { MarkdownRenderEnv } from "./markdown-render-options.ts"; import { installMarkdownSessionLinks, SESSION_LINK_SCAN_RE } from "./markdown-session-links.ts"; import { installMarkdownTables } from "./markdown-tables.ts"; @@ -152,6 +153,7 @@ export function createMarkdownParser(): MarkdownIt { installAssistantTranscriptRoleMarkdown(markdownParser, escapeMarkdownHtml); installMarkdownDetails(markdownParser); installMarkdownTables(markdownParser); + installMarkdownFootnotes(markdownParser); // Disable fuzzy link detection to prevent bare filenames like "README.md" // from being auto-linked as "http://README.md". URLs with explicit protocol diff --git a/ui/src/components/markdown-render-options.ts b/ui/src/components/markdown-render-options.ts index ca5f3515e6f0..2ab41f98be1d 100644 --- a/ui/src/components/markdown-render-options.ts +++ b/ui/src/components/markdown-render-options.ts @@ -14,12 +14,29 @@ export type MarkdownRenderOptions = { mode?: MarkdownRenderMode; sessionLinks?: boolean; tableInteractions?: MarkdownTableInteractions; + /** Stable per-document identity; namespaces generated anchor ids (footnotes) + * so identical notes in adjacent transcript messages stay distinct. */ + documentId?: string; }; -export type MarkdownRenderEnv = Required & { +export type MarkdownRenderEnv = Required> & { streamingOpenFence?: boolean; + docId?: string; }; +// FNV-1a 32-bit folded to base36: compact and selector-safe for anchor ids. +function markdownDocumentId(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + export function normalizeMarkdownRenderOptions( options: MarkdownRenderOptions = {}, ): MarkdownRenderEnv { @@ -34,5 +51,6 @@ export function normalizeMarkdownRenderOptions( mode: options.mode ?? "message", sessionLinks: options.sessionLinks ?? false, tableInteractions: options.tableInteractions ?? "none", + docId: markdownDocumentId(options.documentId), }; } diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index bbb258aad7ff..a91bb32f7cf4 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -615,6 +615,68 @@ PY }); }); + describe("footnotes", () => { + it("moves footnotes to navigable endnotes with local backlinks", () => { + const fragment = htmlFragment( + toSanitizedMarkdownHtml("Claim[^source].\n\n[^source]: Supporting **detail**.", { + documentId: "message-1", + }), + ); + const reference = fragment.querySelector(".footnote-ref"); + const note = fragment.querySelector(".footnote-item"); + const backlink = fragment.querySelector(".footnote-backref"); + + expect(reference?.textContent).toBe("1"); + expect(reference?.getAttribute("href")).toBe(`#${note?.id}`); + expect(backlink?.getAttribute("href")).toBe(`#${reference?.id}`); + expect(backlink?.getAttribute("aria-label")).toBe("Back"); + // Note bodies render as Markdown, not escaped source. + expect(note?.querySelector("strong")?.textContent).toBe("detail"); + expect(fragment.textContent).not.toContain("[^source]"); + }); + + it("namespaces identical notes from adjacent transcript messages", () => { + const markdown = "Claim[^note].\n\n[^note]: Detail."; + const first = htmlFragment(toSanitizedMarkdownHtml(markdown, { documentId: "message-1" })); + const second = htmlFragment(toSanitizedMarkdownHtml(markdown, { documentId: "message-2" })); + + const firstRef = first.querySelector(".footnote-ref"); + const secondRef = second.querySelector(".footnote-ref"); + expect(firstRef?.getAttribute("href")).not.toBe(secondRef?.getAttribute("href")); + // Each note resolves inside its own message's section. + const firstNote = first.querySelector(firstRef?.getAttribute("href") ?? ""); + expect(firstNote?.classList.contains("footnote-item")).toBe(true); + }); + + it("numbers repeated references and backlinks them individually", () => { + const fragment = htmlFragment( + toSanitizedMarkdownHtml("A[^x] B[^x]\n\n[^x]: Note.", { documentId: "m" }), + ); + const refs = [...fragment.querySelectorAll(".footnote-ref")]; + const backlinks = [...fragment.querySelectorAll(".footnote-backref")]; + + expect(refs.map((ref) => ref.textContent)).toEqual(["1", "1"]); + expect(new Set(refs.map((ref) => ref.id)).size).toBe(2); + expect(backlinks.length).toBe(2); + for (const [index, backlink] of backlinks.entries()) { + expect(backlink.getAttribute("href")).toBe(`#${refs[index]?.id}`); + } + }); + + it("keeps undefined footnote references readable as source text", () => { + const html = toSanitizedMarkdownHtml("Claim[^missing] with no note."); + expect(html).toBe("

    Claim[^missing] with no note.

    \n"); + expect(html).not.toContain("footnote"); + }); + + it("keeps dollar amounts as plain text without math elements", () => { + const fragment = htmlFragment(toSanitizedMarkdownHtml("The total is $50 and E = mc^2.")); + expect(fragment.querySelector("math")).toBeNull(); + expect(fragment.textContent).toContain("$50"); + expect(fragment.textContent).toContain("E = mc^2"); + }); + }); + describe("assistant transcript-role annotations", () => { it("marks parsed role headers without exposing Markdown delimiters", () => { const fragment = htmlFragment( diff --git a/ui/src/components/markdown.ts b/ui/src/components/markdown.ts index 0cae9edb7e16..62ed31f4fc9b 100644 --- a/ui/src/components/markdown.ts +++ b/ui/src/components/markdown.ts @@ -45,6 +45,7 @@ const allowedTags = [ "p", "pre", "s", + "section", "span", "strong", "summary", @@ -63,6 +64,7 @@ const allowedAttrs = [ "class", "disabled", "href", + "id", "open", "rel", "target", @@ -450,6 +452,12 @@ function installHooks() { return; } + // Same-document anchors (footnote references and backlinks) must keep their + // fragment behavior; the generic rewrite below would force target="_blank". + if (href.startsWith("#")) { + return; + } + if (isHostLocalMarkdownFileHref(href)) { node.removeAttribute("href"); return; @@ -551,7 +559,7 @@ export function toSanitizedMarkdownHtml( } const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input; const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS; - const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.codeBlockInteraction}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderOptions.linkFavicons}\0${renderOptions.progressBars}\0${renderOptions.mode}\0${renderOptions.sessionLinks}\0${renderOptions.tableInteractions}\0${renderInput}`; + const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.codeBlockInteraction}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderOptions.linkFavicons}\0${renderOptions.progressBars}\0${renderOptions.mode}\0${renderOptions.sessionLinks}\0${renderOptions.tableInteractions}\0${renderOptions.docId ?? ""}\0${renderInput}`; if (cacheable) { const cached = getCachedMarkdown(cacheKey); if (cached !== null) { diff --git a/ui/src/pages/chat/components/chat-divider.ts b/ui/src/pages/chat/components/chat-divider.ts index 97aaa9bcde61..e3b23964fe46 100644 --- a/ui/src/pages/chat/components/chat-divider.ts +++ b/ui/src/pages/chat/components/chat-divider.ts @@ -86,7 +86,12 @@ export function renderChatNotice(item: Extract) { ${item.text ? html`
    - ${unsafeHTML(toSanitizedMarkdownHtml(item.text, { codeBlockChrome: "none" }))} + ${unsafeHTML( + toSanitizedMarkdownHtml(item.text, { + codeBlockChrome: "none", + documentId: item.key, + }), + )}
    ` : nothing} diff --git a/ui/src/pages/chat/components/chat-message-bubble.ts b/ui/src/pages/chat/components/chat-message-bubble.ts index ee19accd2d83..8afa535420a0 100644 --- a/ui/src/pages/chat/components/chat-message-bubble.ts +++ b/ui/src/pages/chat/components/chat-message-bubble.ts @@ -303,6 +303,7 @@ export function renderGroupedMessage( sessionLinks: true, tableInteractions: "enabled", linkFavicons: Boolean(opts.fetchLinkFavicon) && !opts.isStreaming, + documentId: messageKey, }; // Detect pure-JSON messages and render as collapsible block @@ -536,6 +537,7 @@ export function renderGroupedMessage( ${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown, { codeBlockInteraction: "interactive", + documentId: `reasoning:${messageKey}`, }), )} ` @@ -610,6 +612,7 @@ export function renderGroupedMessage( ${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown, { codeBlockInteraction: "interactive", + documentId: `reasoning:${messageKey}`, }), )} ` diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 0056136ec045..4bf73be781e3 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -997,6 +997,7 @@ describe("grouped chat rendering", () => { assistantTranscriptRoleHeaders: false, codeBlockChrome: "none", codeBlockInteraction: "static", + documentId: "user-message", fileLinks: true, interactiveImages: false, linkFavicons: false, @@ -1121,6 +1122,7 @@ describe("grouped chat rendering", () => { assistantTranscriptRoleHeaders: true, codeBlockChrome: "copy", codeBlockInteraction: "interactive", + documentId: "assistant-message", fileLinks: true, interactiveImages: false, linkFavicons: false, @@ -1669,6 +1671,7 @@ describe("grouped chat rendering", () => { assistantTranscriptRoleHeaders: true, codeBlockChrome: "copy", codeBlockInteraction: "interactive", + documentId: "stream:1", fileLinks: true, interactiveImages: false, linkFavicons: false, @@ -2061,6 +2064,7 @@ describe("grouped chat rendering", () => { expect(notice?.dataset.chatRowKey).toBe("notice:command"); expect(markdownRenderMock).toHaveBeenCalledWith(expect.any(String), { codeBlockChrome: "none", + documentId: "notice:command", }); }); diff --git a/ui/src/pages/chat/components/chat-session-rail.ts b/ui/src/pages/chat/components/chat-session-rail.ts index 288575ce01aa..1f687f3cd02b 100644 --- a/ui/src/pages/chat/components/chat-session-rail.ts +++ b/ui/src/pages/chat/components/chat-session-rail.ts @@ -424,7 +424,11 @@ export class ChatSessionRailElement extends OpenClawLightDomElement { ${question}
    - ${unsafeHTML(toSanitizedMarkdownHtml(answer))} + ${unsafeHTML( + toSanitizedMarkdownHtml(answer, { + documentId: `rail:${this.sessionKey}:${ts}`, + }), + )}