From cf5fdd87123c783ca53a333ac70d53209badb8d5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 8 May 2026 18:30:51 -0700 Subject: [PATCH] feat(web): render inline Markdown in run titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add InlineMarkdown component that tokenizes titles via marked's Lexer.lexInline and renders code, strong, and em. Block syntax, links, images, and raw HTML degrade to safe text — no dangerouslySetInnerHTML. Applied to the run detail h2 heading and the run list row titles. Title metadata, search, and the API/server data model are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/inline-markdown.test.tsx | 110 ++++++++++++++++++ .../app/components/inline-markdown.tsx | 51 ++++++++ apps/fabro-web/app/routes/run-detail.test.ts | 39 ++++++- apps/fabro-web/app/routes/run-detail.tsx | 5 +- apps/fabro-web/app/routes/runs.tsx | 3 +- apps/fabro-web/app/routes/workflow-runs.tsx | 3 +- 6 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 apps/fabro-web/app/components/inline-markdown.test.tsx create mode 100644 apps/fabro-web/app/components/inline-markdown.tsx diff --git a/apps/fabro-web/app/components/inline-markdown.test.tsx b/apps/fabro-web/app/components/inline-markdown.test.tsx new file mode 100644 index 000000000..ae9bef4db --- /dev/null +++ b/apps/fabro-web/app/components/inline-markdown.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { InlineMarkdown } from "./inline-markdown"; + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +async function render(content: string, className?: string) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let renderer: TestRenderer.ReactTestRenderer | undefined; + await act(async () => { + renderer = TestRenderer.create( + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function textFromInstance(node: TestRenderer.ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : textFromInstance(child))) + .join(""); +} + +describe("InlineMarkdown", () => { + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test("renders backtick spans as without visible backticks", async () => { + const renderer = await render( + "Move from `[server.integrations.github]` to `[run.integrations.github]`", + ); + + const codes = renderer.root.findAllByType("code"); + expect(codes).toHaveLength(2); + expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]"); + expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]"); + + const fullText = textFromInstance(renderer.root); + expect(fullText).not.toContain("`"); + }); + + test("renders **bold** as and _italic_ as ", async () => { + const renderer = await render("**bold** and _italic_"); + + const strongs = renderer.root.findAllByType("strong"); + expect(strongs).toHaveLength(1); + expect(textFromInstance(strongs[0]!)).toBe("bold"); + + const ems = renderer.root.findAllByType("em"); + expect(ems).toHaveLength(1); + expect(textFromInstance(ems[0]!)).toBe("italic"); + }); + + test("renders *italic* (asterisk form) as ", async () => { + const renderer = await render("*italic*"); + const ems = renderer.root.findAllByType("em"); + expect(ems).toHaveLength(1); + expect(textFromInstance(ems[0]!)).toBe("italic"); + }); + + test("block markdown like headings, lists, blockquotes stays text", async () => { + const renderer = await render("# heading - item > quote"); + + expect(renderer.root.findAllByType("h1")).toHaveLength(0); + expect(renderer.root.findAllByType("h2")).toHaveLength(0); + expect(renderer.root.findAllByType("ul")).toHaveLength(0); + expect(renderer.root.findAllByType("li")).toHaveLength(0); + expect(renderer.root.findAllByType("blockquote")).toHaveLength(0); + + expect(textFromInstance(renderer.root)).toContain("# heading"); + expect(textFromInstance(renderer.root)).toContain("> quote"); + }); + + test("link syntax renders the label as text without an ", async () => { + const renderer = await render("[label](javascript:alert(1))"); + + expect(renderer.root.findAllByType("a")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain("label"); + expect(textFromInstance(renderer.root)).not.toContain("javascript:"); + }); + + test("image syntax renders the alt text without an ", async () => { + const renderer = await render("![alt](x)"); + + expect(renderer.root.findAllByType("img")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain("alt"); + }); + + test("raw HTML is shown literally as text, not interpreted", async () => { + const renderer = await render(""); + + expect(renderer.root.findAllByType("script")).toHaveLength(0); + expect(textFromInstance(renderer.root)).toContain(""); + }); + + test("applies a className to the wrapper span", async () => { + const renderer = await render("hello", "text-fg-2"); + const wrapper = renderer.root.findByType("span"); + expect(wrapper.props.className).toBe("text-fg-2"); + }); +}); diff --git a/apps/fabro-web/app/components/inline-markdown.tsx b/apps/fabro-web/app/components/inline-markdown.tsx new file mode 100644 index 000000000..9ff7a8212 --- /dev/null +++ b/apps/fabro-web/app/components/inline-markdown.tsx @@ -0,0 +1,51 @@ +import { Fragment, type ReactNode } from "react"; +import { Lexer, type Token } from "marked"; + +const CODE_CLASSNAME = + "rounded bg-overlay-strong px-1 py-0.5 font-mono text-[0.85em] text-fg-2"; + +function renderTokens(tokens: Token[]): ReactNode[] { + return tokens.map((token, index) => ( + {renderToken(token)} + )); +} + +function renderToken(token: Token): ReactNode { + switch (token.type) { + case "codespan": + return {token.text}; + case "strong": + return {renderTokens(token.tokens)}; + case "em": + return {renderTokens(token.tokens)}; + case "del": + return renderTokens(token.tokens); + case "link": + return token.tokens.length > 0 ? renderTokens(token.tokens) : token.text; + case "image": + return token.text; + case "html": + return token.raw; + case "br": + return " "; + case "escape": + return token.text; + case "text": + return token.tokens && token.tokens.length > 0 + ? renderTokens(token.tokens) + : token.text; + default: + return "raw" in token ? token.raw : ""; + } +} + +export function InlineMarkdown({ + content, + className, +}: { + content: string; + className?: string; +}) { + const tokens = Lexer.lexInline(content); + return {renderTokens(tokens)}; +} diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index f6c4f0619..e9c3d6a5a 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -71,10 +71,15 @@ type RunDetailActionResult = import("./run-detail").RunDetailActionResult; const h = createElement; -function makeRunSummary(status = "succeeded", diffSummary: any = null, pullRequest: any = null) { +function makeRunSummary( + status = "succeeded", + diffSummary: any = null, + pullRequest: any = null, + title = "Run 1", +) { return { run_id: "run_1", - title: "Run 1", + title, repository: { name: "fabro" }, status: { kind: status }, workflow_slug: "default", @@ -111,14 +116,16 @@ async function renderRunDetail({ questions = [], diffSummary = null, pullRequest = null, + title, }: { initialEntry: string; status?: string; questions?: any[]; diffSummary?: any; pullRequest?: any; + title?: string; }) { - currentRunSummary = makeRunSummary(status, diffSummary, pullRequest); + currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title); currentQuestions = questions; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -453,6 +460,32 @@ describe("RunDetail full-height child routes", () => { expect(clearanceOwners.length).toBeGreaterThan(0); }); + test("renders inline in the run title heading for Markdown-formatted titles", async () => { + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + title: "Move from `[server.integrations.github]` to `[run.integrations.github]`", + }); + + const headings = renderer.root.findAll( + (node) => + node.type === "h2" && + hasClasses(node.props.className, ["text-xl", "font-semibold", "text-fg"]), + ); + expect(headings).toHaveLength(1); + + const codes = headings[0]!.findAllByType("code"); + expect(codes).toHaveLength(2); + expect( + codes + .map((code) => + code.children.filter((child) => typeof child === "string").join(""), + ), + ).toEqual([ + "[server.integrations.github]", + "[run.integrations.github]", + ]); + }); + test("preserves document-flow layout for child routes without fullHeight", async () => { const renderer = await renderRunDetail({ initialEntry: "/runs/run_1", diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 63bcffe0b..fb9844d7c 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -16,6 +16,7 @@ import { import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import { InlineMarkdown } from "../components/inline-markdown"; import { InterviewDock } from "../components/interview-dock"; import { GitPullRequestIcon } from "../components/icons"; import { SteerBar, type SteerBarHandle } from "../components/steer-bar"; @@ -296,7 +297,9 @@ export default function RunDetail({ params }: { params: { id: string } }) { )} >
-

{run.title}

+

+ +

diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 93dcbbaba..032394035 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -26,6 +26,7 @@ import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnSta import { formatRelativeTime } from "../lib/format"; import { EmptyState } from "../components/state"; import { GitPullRequestIcon } from "../components/icons"; +import { InlineMarkdown } from "../components/inline-markdown"; import { useToast } from "../components/toast"; import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events"; import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries"; @@ -606,7 +607,7 @@ function RunRow({ run }: { run: RunWithStatus }) { {run.repo} - {run.title} + {lifecycleLabel != null && ( {lifecycleLabel} diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index c3b0190f4..0811f4320 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { Link, useParams } from "react-router"; import { GitPullRequestIcon } from "../components/icons"; +import { InlineMarkdown } from "../components/inline-markdown"; import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs"; import type { ColumnStatus, RunWithStatus } from "../data/runs"; import { useWorkflowRuns } from "../lib/queries"; @@ -36,7 +37,7 @@ function RunRow({ run }: { run: RunWithStatus }) { - {run.title} + {run.comments != null && run.comments > 0 && (