fix(ui): keep inline document links in the text line; plain-text request previews (#19639)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 17:41:07 -06:00
1 parent c82211251b
commit 07a548073a
9 files changed
+187 -10

No files matched your search

@@ -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<HTMLAnchorElement>("[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");
+4 -4
View File
@@ -186,8 +186,8 @@ function MarkdownIssueLink({
}
if (documentLink) {
return <SidebarDocumentLink href={href} onPointerEnter={() => setEngaged(true)} onFocus={() => setEngaged(true)}>
<span className="min-w-0 truncate text-foreground underline underline-offset-4">{children}</span>
return <SidebarDocumentLink href={href} variant="inline" onPointerEnter={() => setEngaged(true)} onFocus={() => setEngaged(true)}>
<span className="underline underline-offset-4">{children}</span>
</SidebarDocumentLink>;
}
@@ -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 <SidebarDocumentLink href={href}>
<span className="min-w-0 truncate text-foreground underline underline-offset-4">{linkChildren}</span>
return <SidebarDocumentLink href={href} variant="inline">
<span className="underline underline-offset-4">{linkChildren}</span>
</SidebarDocumentLink>;
}
if (issueRef) {
+12 -4
View File
@@ -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 <Link to={href} disableIssueQuicklook aria-label={ariaLabel} data-testid={testId}
onPointerEnter={onPointerEnter} onFocus={onFocus}
data-sidebar-document-link
className={cn("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)}>
<FileText className="size-4 shrink-0" aria-hidden />{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)}>
<FileText className={inline ? "mr-1 inline-block size-3.5 shrink-0 align-text-bottom text-muted-foreground" : "size-4 shrink-0"} aria-hidden />{children}
</Link>;
}
@@ -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(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(createRequestConfirmation({ title: "Review the creative brief (v1)", summary }))}
showPendingRequest
onReviewRequest={() => {}}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
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",
@@ -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"
>
<CircleHelp aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1"><span className="block text-xs">Unanswered question</span><span className="block truncate">{prompt}</span></span>
<span className="min-w-0 flex-1"><span className="block text-xs">Unanswered question</span><span className="block truncate">{markdownToPlainText(prompt)}</span></span>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
</button>
);
@@ -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">
<CircleHelp aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1"><span className="block text-xs">{label}</span><span className="block truncate">{interaction.summary ?? ("prompt" in interaction.payload ? interaction.payload.prompt : null)}</span></span>
<span className="min-w-0 flex-1"><span className="block text-xs">{label}</span><span className="block truncate">{previewText}</span></span>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
</button>
</div>
+40
View File
@@ -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 <https://example.com/brief_v2> before approving, or ask <team@example.com>. <b>Bold</b>"))
.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?");
});
});
+50
View File
@@ -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 (<https://…>, <name@host>): 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();
}
@@ -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 <CoordinatorPresentation.Provider value><div className="coordinator-workspace min-h-dvh bg-background p-6 text-foreground"><div className="mx-auto max-w-3xl space-y-8">
<TaskChatBubble animateEntry={false} item={{ id: "inline-document-link-agent", kind: "message", author: "agent", authorName: "Head baker", text: inlineDocumentLinkText, createdAtIso: new Date(Date.now() - 60_000).toISOString() }} />
</div></div></CoordinatorPresentation.Provider>;
}
/** 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: () => <InlineDocumentLinkConversation />,
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" } };
@@ -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 }) => <div className="min-h-dvh bg-background p-6 text-foreground"><div className="mx-auto max-w-3xl">
<TaskChatInteractionCard item={{ id: `interaction:${interaction.id}`, kind: "interaction", interaction }}
showPendingRequest onReviewRequest={() => {}} agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId} />
</div></div>,
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(/[`*]/);
},
};