Files
PaperClipAI/ui/src/components/MarkdownBody.interaction.test.tsx
T
DottaandClaude Opus 5.5 07a548073a 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>
2026-11-06 17:41:07 -06:00

123 lines
4.3 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import { issuesApi } from "../api/issues";
import { ApiError } from "../api/client";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function renderMarkdown(children: string, coordinator = false) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<CoordinatorPresentation.Provider value={coordinator}>
<MarkdownBody>{children}</MarkdownBody>
</CoordinatorPresentation.Provider>
</ThemeProvider>
</QueryClientProvider>,
);
});
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
flushSync(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("MarkdownBody code block interactions", () => {
it("retains unavailable-task handling for compact document links", async () => {
vi.mocked(issuesApi.get).mockRejectedValueOnce(new ApiError("Not found", 404, null));
const node = renderMarkdown("[View recommendations](/issues/TES-6#document-recommendations)", true);
const link = node.querySelector<HTMLAnchorElement>('[data-sidebar-document-link]')!;
expect(link).toBeTruthy();
await act(async () => { link.focus(); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(node.querySelector('[data-testid="locked-issue-chip"]')).toBeTruthy();
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");
const wrapButton = node.querySelector<HTMLButtonElement>(".paperclip-markdown-codeblock-wrap");
expect(pre?.style.whiteSpace).toBe("");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
click(wrapButton);
expect(pre?.style.whiteSpace).toBe("pre-wrap");
expect(pre?.style.overflowWrap).toBe("anywhere");
expect(wrapButton?.getAttribute("aria-pressed")).toBe("true");
expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines");
click(wrapButton);
expect(pre?.style.whiteSpace).toBe("");
expect(wrapButton?.getAttribute("aria-pressed")).toBe("false");
expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines");
});
});