From 07a548073ab48f13cb93b2fdc31553a5da88a070 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:41:07 -0600 Subject: [PATCH] fix(ui): keep inline document links in the text line; plain-text request previews (#19639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents talk to people in task chat. Their messages render as markdown. > - In an onboarding walk, two chat papercuts made agent text hard to read. > - First: a document link inside a sentence ("Your [plan is saved], approved, ...") rendered as a padded card. The card is taller than the text line, so it overlaps the lines around it. > - Second: the one-line row above a pending review card showed the agent's raw markdown summary (backticks, "- ", "**"). > - This pull request renders document links in running text as an inline chip, and flattens the collapsed preview to plain text. > - The benefit is that agent messages and pending-request rows read cleanly. ## Linked Issues or Issue Description **What happened?** 1. In task chat, an agent wrote: "Yes—we completed the onboarding interview here in chat. Your [plan is saved](#document-plan), approved, and handed off to execution task PIX-3." The "plan is saved" link rendered as a 42px-tall bordered card in a 20px text line. It overlapped the text above and below and left the text baseline. 2. The collapsed row above a "Review the creative brief (v1)" card read: "The `creative-brief` document is saved on this task. It covers: - **Brand voice and audience:** ..." with literal markdown syntax. **Steps to reproduce** 1. Open a coordinator task chat where an agent message has a `#document-...` link inside a sentence. Or open the Storybook story `coordinator-conversation--inline-document-link`. 2. Open a task with a pending request whose summary contains markdown. Or open `composer-interaction-above-composer--pending-request-markdown-summary`. **Expected behavior** 1. The document link sits in the text line like other inline links. 2. The collapsed preview shows the words as plain text. ## What Changed - `SidebarDocumentLink` gets a `variant` prop. `card` (default) is unchanged and is still used by the standalone plan preview card. `inline` is a text-flow chip: `display: inline`, horizontal padding only, a small muted background, and a 14px icon aligned to the text bottom. Token classes only. - `MarkdownBody` uses the `inline` variant for both document-link paths (coordinator issue document links and `#document-...` hash links). The label no longer uses `truncate` (nowrap), so long labels wrap with the sentence. - New `ui/src/lib/markdown-plain-text.ts` (`markdownToPlainText`): drops code ticks, emphasis/strikethrough markers, list/heading/quote/task-box prefixes, fences, rules, link/image targets and inline HTML tags, keeps snake_case intact, and collapses whitespace. Unit tests included. - `TaskChatInteractionCard` flattens the collapsed pending-request summary and the unanswered-question prompt with `markdownToPlainText`. - Stories: `Coordinator conversation / Inline document link` (+ dark) with a play check that the link is no taller than the line and starts inside the paragraph; `Interaction above composer / Pending request markdown summary` with a play check that the row has no markdown syntax. ## Verification Red on master (`e482f0f632`), then green with the fix: ``` # Red (master + new tests) × renders a document link inside a sentence as inline text, not a padded card AssertionError: expected 'inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-3 py-2.5 ...' not to match /(^|\s)(inline-flex|flex|block|py-\S+|p-\S+)(\s|$)/ × shows the collapsed pending-request preview as plain text, not raw markdown AssertionError: expected 'Review the creative brief (v1)The `cr…' to contain 'The creative-brief document is saved …' Tests 2 failed # Green (fix) Tests 7 passed (the 2 above + 5 markdownToPlainText unit tests) ``` Browser geometry check (Playwright against Storybook, story `coordinator-conversation--inline-document-link`, light and dark). It measures the link box against the paragraph line-height and the text node before the link: ``` # Before light: FAIL lineHeight=20 chipHeight=42.0 chipTop=61.7 chipBottom=103.7 textTop=76.7 textBottom=93.7 dark: FAIL lineHeight=20 chipHeight=42.0 chipTop=61.7 chipBottom=103.7 textTop=76.7 textBottom=93.7 # After light: PASS lineHeight=20 chipHeight=17.0 chipTop=62.7 chipBottom=79.7 textTop=62.7 textBottom=79.7 dark: PASS lineHeight=20 chipHeight=17.0 chipTop=62.7 chipBottom=79.7 textTop=62.7 textBottom=79.7 ``` The story's own play function also failed on master in the browser: `AssertionError: expected 42 to be less than or equal to 20`. Inline document link, before / after (dark, then light): ![chip before dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-before-dark.png) ![chip after dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-after-dark.png) ![chip before light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-before-light.png) ![chip after light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-after-light.png) Collapsed pending-request preview, before / after: ![preview before dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-before-dark.png) ![preview after dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-after-dark.png) ![preview before light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-before-light.png) ![preview after light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-after-light.png) Neighbouring suites: `npx vitest run src/components/MarkdownBody src/components/task-chat src/lib/markdown-plain-text.test.ts src/components/MarkdownDocumentCardStyles.test.ts src/components/MarkdownExternalLink.test.tsx` → 67 files, 1001 tests passed. Also run: `pnpm check:token-gates` (clean), UI `tsc -b` (no errors in changed files; the only errors were an unbuilt local `@paperclipai/plugin-sdk`), `node scripts/storybook-catalog.mjs`. `pnpm verify` exited 2 (story file is a broad input), so broad coverage comes from required CI. ## Risks - Low risk. UI-only styling and text change. - Document links in markdown now look like inline links with a small icon instead of a card. A link that sits alone in its own paragraph also renders inline. The standalone plan preview card keeps the card style. - `markdownToPlainText` is a regex flattener for one-line previews, not a full markdown parser. An unusual construct can keep a stray marker. It does not change stored data. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, Playwright browser checks). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../MarkdownBody.interaction.test.tsx | 10 ++++ ui/src/components/MarkdownBody.tsx | 8 +-- ui/src/components/SidebarDocumentLink.tsx | 16 ++++-- .../TaskChatInteractionCard.test.tsx | 22 ++++++++ .../task-chat/TaskChatInteractionCard.tsx | 8 ++- ui/src/lib/markdown-plain-text.test.ts | 40 +++++++++++++++ ui/src/lib/markdown-plain-text.ts | 50 +++++++++++++++++++ .../coordinator-conversation.stories.tsx | 22 ++++++++ ...hat-interaction-above-composer.stories.tsx | 21 ++++++++ 9 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 ui/src/lib/markdown-plain-text.test.ts create mode 100644 ui/src/lib/markdown-plain-text.ts diff --git a/ui/src/components/MarkdownBody.interaction.test.tsx b/ui/src/components/MarkdownBody.interaction.test.tsx index 90f0d4bad09..6931815af35 100644 --- a/ui/src/components/MarkdownBody.interaction.test.tsx +++ b/ui/src/components/MarkdownBody.interaction.test.tsx @@ -88,6 +88,16 @@ describe("MarkdownBody code block interactions", () => { expect(node.querySelector("a")).toBeNull(); expect(node.textContent).not.toContain("View recommendations"); }); + it("renders a document link inside a sentence as inline text, not a padded card", () => { + const node = renderMarkdown("Your [plan is saved](#document-plan), approved, and handed off.", true); + const link = node.querySelector("[data-sidebar-document-link]")!; + expect(link).toBeTruthy(); + expect(link.closest("p")).toBeTruthy(); + // A card (inline-flex box with vertical padding and a full-size icon) is + // taller than the text line and overlaps the lines around it. + expect(link.className).not.toMatch(/(^|\s)(inline-flex|flex|block|py-\S+|p-\S+)(\s|$)/); + expect(link.className).toMatch(/(^|\s)inline(\s|$)/); + }); it("toggles line wrapping for indented preformatted markdown blocks", () => { const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox"); const pre = node.querySelector("pre"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 638f4c1436d..2630566e3db 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -186,8 +186,8 @@ function MarkdownIssueLink({ } if (documentLink) { - return setEngaged(true)} onFocus={() => setEngaged(true)}> - {children} + return setEngaged(true)} onFocus={() => setEngaged(true)}> + {children} ; } @@ -872,8 +872,8 @@ function MarkdownBodyImpl({ const documentHash = href?.includes("#") ? href.slice(href.indexOf("#")) : null; const documentLink = Boolean(coordinator && documentHash && parseDocumentAnnotationHash(documentHash)); if (documentLink && href?.startsWith("#")) { - return - {linkChildren} + return + {linkChildren} ; } if (issueRef) { diff --git a/ui/src/components/SidebarDocumentLink.tsx b/ui/src/components/SidebarDocumentLink.tsx index 8bde96f61e4..af6377d5b00 100644 --- a/ui/src/components/SidebarDocumentLink.tsx +++ b/ui/src/components/SidebarDocumentLink.tsx @@ -3,8 +3,12 @@ import { FileText } from "lucide-react"; import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; -/** Compact document affordance shared by saved plans and authored document links. */ -export function SidebarDocumentLink({ href, children, ariaLabel, testId, className, onPointerEnter, onFocus }: { +/** + * Compact document affordance shared by saved plans and authored document links. + * `card` is a standalone padded box; `inline` sits in running text (markdown + * paragraphs) and must not grow taller than the text line it is part of. + */ +export function SidebarDocumentLink({ href, children, ariaLabel, testId, className, onPointerEnter, onFocus, variant = "card" }: { href: string; children: ReactNode; ariaLabel?: string; @@ -12,11 +16,15 @@ export function SidebarDocumentLink({ href, children, ariaLabel, testId, classNa className?: string; onPointerEnter?: () => void; onFocus?: () => void; + variant?: "card" | "inline"; }) { + const inline = variant === "inline"; return - {children} + className={cn(inline + ? "inline rounded-sm bg-muted/40 px-1 box-decoration-clone text-foreground transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + : "inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}> + {children} ; } diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 7b2763446ad..17bc33ffd15 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -240,6 +240,28 @@ describe("TaskChatInteractionCard", () => { expect(container.textContent).not.toContain("Approve the plan?"); }); + it("shows the collapsed pending-request preview as plain text, not raw markdown", () => { + const summary = "The `creative-brief` document is saved on this task. It covers:\n\n- **Brand voice and audience:** Gen Z core buyers and holiday gift buyers.\n- __Style rules:__ see [the brief](#document-creative-brief) and ~~old~~ *new* notes."; + flushSync(() => { + root.render( + + + {}} + /> + + , + ); + }); + const row = container.querySelector('[data-testid="task-chat-pending-request"]'); + expect(row).not.toBeNull(); + const preview = row!.textContent ?? ""; + expect(preview).toContain("The creative-brief document is saved on this task. It covers: Brand voice and audience: Gen Z core buyers and holiday gift buyers. Style rules: see the brief and old new notes."); + expect(preview).not.toMatch(/[`*_~[\]]|(^|\s)- /); + }); + it("renders a plan confirmation as a linked document preview", () => { const interaction = createRequestConfirmation({ sourceRunId: "run-plan", diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.tsx index 93b931b9981..c32f8d0a618 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.tsx @@ -9,6 +9,7 @@ import { CircleHelp, ChevronRight } from "lucide-react"; import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions"; import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard"; import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard"; +import { markdownToPlainText } from "@/lib/markdown-plain-text"; import type { TaskChatInteractionItem } from "./task-chat-model"; import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; @@ -67,7 +68,7 @@ export function TaskChatInteractionCard({ className="group flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > - Unanswered question{prompt} + Unanswered question{markdownToPlainText(prompt)} ); @@ -95,6 +96,9 @@ export function TaskChatInteractionCard({ && interaction.kind !== "connection_intent" && interaction.kind !== "ask_user_questions" && !shouldHideInteractionCard(interaction)) { const label = interaction.title ?? (interaction.kind === "suggest_tasks" ? "Suggested tasks" : "Review request"); + const preview = interaction.summary ?? ("prompt" in interaction.payload ? interaction.payload.prompt : null); + // One truncated line: show the words, not the markdown syntax. + const previewText = preview ? markdownToPlainText(preview) : null; const showPlan = interaction.kind === "request_confirmation" && interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan" && planDocument && showPlanPreview @@ -109,7 +113,7 @@ export function TaskChatInteractionCard({ onClick={() => onReviewRequest?.(interaction.id)} className="group flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> - {label}{interaction.summary ?? ("prompt" in interaction.payload ? interaction.payload.prompt : null)} + {label}{previewText} diff --git a/ui/src/lib/markdown-plain-text.test.ts b/ui/src/lib/markdown-plain-text.test.ts new file mode 100644 index 00000000000..0d7a5f69e19 --- /dev/null +++ b/ui/src/lib/markdown-plain-text.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { markdownToPlainText } from "./markdown-plain-text"; + +describe("markdownToPlainText", () => { + it("flattens the creative-brief summary from the onboarding walk", () => { + expect(markdownToPlainText( + "The `creative-brief` document is saved on this task. It covers:\n\n- **Brand voice and audience:** Gen Z core buyers\n- **10 trend themes, plus 1 seasonal,** each with a lifespan rating", + )).toBe("The creative-brief document is saved on this task. It covers: Brand voice and audience: Gen Z core buyers 10 trend themes, plus 1 seasonal, each with a lifespan rating"); + }); + + it("keeps link and image text but drops their targets", () => { + expect(markdownToPlainText("Open [the plan](#document-plan) and ![logo sketch](/files/logo.png).")) + .toBe("Open the plan and logo sketch."); + }); + + it("drops headings, quotes, numbered lists, task boxes and code fences", () => { + expect(markdownToPlainText("## Next steps\n> Note\n1. First\n2) Second\n- [x] Done\n```ts\nconst a = 1;\n```")) + .toBe("Next steps Note First Second Done const a = 1;"); + }); + + it("strips emphasis without touching snake_case, arithmetic, or lone markers", () => { + expect(markdownToPlainText("Set __max_retries__ in my_config_file, *not* 2 * 3 or _this_. ~~old~~")) + .toBe("Set max_retries in my_config_file, not 2 * 3 or this. old"); + }); + + it("keeps autolinked URLs and email addresses", () => { + expect(markdownToPlainText("Review before approving, or ask . Bold")) + .toBe("Review https://example.com/brief_v2 before approving, or ask team@example.com. Bold"); + }); + + it("keeps code verbatim, including dunder identifiers and markers inside fences", () => { + expect(markdownToPlainText("Edit `__init__` and `*args`.\n\n```py\ndef f(__x__):\n return **kw\n```\nDone.")) + .toBe("Edit __init__ and *args. def f(__x__): return **kw Done."); + }); + + it("returns plain text unchanged", () => { + expect(markdownToPlainText("Approve the saved plan for a printable menu?")) + .toBe("Approve the saved plan for a printable menu?"); + }); +}); diff --git a/ui/src/lib/markdown-plain-text.ts b/ui/src/lib/markdown-plain-text.ts new file mode 100644 index 00000000000..909a7d2f366 --- /dev/null +++ b/ui/src/lib/markdown-plain-text.ts @@ -0,0 +1,50 @@ +// Private-use sentinels: protected spans are swapped out while markup is +// stripped, so code and autolinks are never edited by the emphasis rules. +const PROTECT_OPEN = "\uE000"; +const PROTECT_CLOSE = "\uE001"; + +/** + * Flatten agent-authored markdown into one line of readable text for + * single-line previews (collapsed rows, truncated summaries). Keeps the words, + * drops the syntax: code ticks, emphasis markers, list/heading/quote prefixes, + * and link/image targets. Code and autolink text is kept verbatim. Not a + * renderer: use MarkdownBody when the formatting itself should be shown. + */ +export function markdownToPlainText(markdown: string): string { + const protectedSpans: string[] = []; + const protect = (text: string) => `${PROTECT_OPEN}${protectedSpans.push(text) - 1}${PROTECT_CLOSE}`; + + return markdown + .replace(new RegExp(`[${PROTECT_OPEN}${PROTECT_CLOSE}]`, "g"), "") + // Fenced code blocks: keep the code, drop the fences. + .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n([\s\S]*?)(?:\n[ \t]*\1[^\n]*|$)/gm, (_match, _fence, code: string) => protect(code)) + .replace(/^[ \t]*(`{3,}|~{3,})[^\n]*$/gm, "") + // Inline code. + .replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_match, _ticks, code: string) => protect(code.trim())) + // Autolinks (, ): keep the address. + .replace(/<((?:[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^\s<>]*)|(?:[^\s<>@]+@[^\s<>@]+))>/g, (_match, address: string) => protect(address)) + // Images, then links: keep the visible text, drop the target. + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") + // Line prefixes: headings, block quotes, list bullets and numbers, task boxes. + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/gm, "") + // Horizontal rules. + .replace(/^\s*(?:[-*_]\s*){3,}$/gm, "") + // Emphasis and strikethrough. Underscore forms must sit on word boundaries + // so snake_case identifiers survive. + .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, (match, marker: string, text: string, offset: number, source: string) => + marker === "__" && (/\w/.test(source[offset - 1] ?? "") || /\w/.test(source[offset + match.length] ?? "")) + ? match + : text) + .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1") + .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2") + .replace(/(^|[^\w_])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2") + // Inline HTML tags. + .replace(/<\/?[a-zA-Z][^>]*>/g, "") + .replace(new RegExp(`${PROTECT_OPEN}(\\d+)${PROTECT_CLOSE}`, "g"), (_match, index: string) => protectedSpans[Number(index)] ?? "") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/ui/storybook/stories/agent-chat/coordinator-conversation.stories.tsx b/ui/storybook/stories/agent-chat/coordinator-conversation.stories.tsx index 563fa2949ab..51a243ea4a1 100644 --- a/ui/storybook/stories/agent-chat/coordinator-conversation.stories.tsx +++ b/ui/storybook/stories/agent-chat/coordinator-conversation.stories.tsx @@ -56,3 +56,25 @@ export const TaskQuestion: Story = { }; export const TaskQuestionDark: Story = { ...TaskQuestion, play: undefined, globals: { theme: "dark" } }; export const TaskQuestionPhone: Story = { ...TaskQuestion, play: undefined, globals: { viewport: { value: "mobile1" } } }; + +const inlineDocumentLinkText = "Yes—we completed the onboarding interview here in chat. Your [plan is saved](#document-plan), approved, and handed off to execution task PIX-3.\n\nPIX-3 is now waiting for your bakery name, products/prices, contact details, and visual preferences."; +function InlineDocumentLinkConversation() { + return
+ +
; +} +/** A document link inside a sentence must stay in the text line: no taller box that overlaps the lines around it. */ +export const InlineDocumentLink: Story = { + render: () => , + parameters: { paperclip: { capabilities: ["agent-chat/history"], status: "current", implementation: "production", boundary: "fixtures", notes: "Production task chat bubble and markdown document link inside running text. Sample content only; proves layout, not document storage or navigation." } }, + play: async ({ canvasElement }) => { + const link = await within(canvasElement).findByRole("link", { name: /plan is saved/ }); + const paragraph = link.closest("p")!; + const lineHeight = Number.parseFloat(getComputedStyle(paragraph).lineHeight); + const box = link.getBoundingClientRect(); + const textBox = paragraph.getBoundingClientRect(); + await expect(box.height).toBeLessThanOrEqual(lineHeight); + await expect(box.top).toBeGreaterThanOrEqual(textBox.top); + }, +}; +export const InlineDocumentLinkDark: Story = { ...InlineDocumentLink, play: undefined, globals: { theme: "dark" } }; diff --git a/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx index d0c3fd1c05a..73582297391 100644 --- a/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx @@ -290,3 +290,24 @@ export const MobileToolReviewWithBottomBar: Story = { args: { interaction: pendingToolActionWriteInteraction, mobile: true }, globals: { viewport: { value: "mobile1", isRotated: false } }, }; + +const markdownSummaryRequest = { + ...genericPendingRequestConfirmationInteraction, + id: "interaction-confirmation-markdown-summary", + title: "Review the creative brief (v1)", + summary: "The `creative-brief` document is saved on this task. It covers:\n\n- **Brand voice and audience:** Gen Z core buyers and holiday gift buyers for Gen Alpha.\n- **10 trend themes, plus 1 seasonal,** each with a lifespan rating and a source.", +}; +/** The collapsed timeline row previews an agent's markdown summary as one line of plain text. */ +export const PendingRequestMarkdownSummary: Story = { + args: { interaction: markdownSummaryRequest }, + render: ({ interaction }) =>
+ {}} agentMap={storybookAgentMap} + currentUserId={issueThreadInteractionFixtureMeta.currentUserId} /> +
, + play: async ({ canvasElement }) => { + const row = await within(canvasElement).findByRole("button", { name: /Open request: Review the creative brief/ }); + await expect(row).toHaveTextContent("The creative-brief document is saved on this task. It covers: Brand voice and audience:"); + await expect(row.textContent).not.toMatch(/[`*]/); + }, +};