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("");
+
+ 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 && (