mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## 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):




Collapsed pending-request preview, before / after:




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>
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
import type { ComponentProps } from "react";
|
|
import type { IssueDocument } from "@paperclipai/shared";
|
|
import type { MentionOption } from "@/components/MarkdownEditor";
|
|
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
|
import { AppLogo } from "@/pages/apps/AppLogo";
|
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
|
import { Button } from "@/components/ui/button";
|
|
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";
|
|
|
|
type InteractionCardProps = Omit<
|
|
ComponentProps<typeof IssueThreadInteractionCard>,
|
|
"interaction"
|
|
>;
|
|
|
|
export interface TaskChatInteractionCardProps extends InteractionCardProps {
|
|
item: TaskChatInteractionItem;
|
|
onReviewRequest?: (interactionId: string) => void;
|
|
showUnansweredQuestion?: boolean;
|
|
showPendingRequest?: boolean;
|
|
planDocument?: IssueDocument | null;
|
|
showPlanPreview?: boolean;
|
|
presentation?: "timeline" | "takeover";
|
|
draftKey?: string;
|
|
mentions?: MentionOption[];
|
|
}
|
|
|
|
/**
|
|
* Task-page presentation for durable issue interactions. The new task view uses
|
|
* a compact, protocol-card-aligned renderer with paginated questions and
|
|
* verdicts; the classic issue thread keeps its existing card. Resolved inputs
|
|
* collapse to one receipt row, with security and audit details preserved in the
|
|
* disclosure.
|
|
*/
|
|
export function TaskChatInteractionCard({
|
|
item,
|
|
onReviewRequest,
|
|
showUnansweredQuestion = false,
|
|
showPendingRequest = false,
|
|
planDocument,
|
|
showPlanPreview = true,
|
|
presentation = "timeline",
|
|
draftKey,
|
|
...cardProps
|
|
}: TaskChatInteractionCardProps) {
|
|
const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled();
|
|
const interaction = item.interaction;
|
|
if (coordinatorEnabled && interaction.kind === "suggest_tasks" && !shouldHideInteractionCard(interaction)) {
|
|
return <div id={`interaction-${interaction.id}`} data-testid="task-chat-interaction" className="w-full">
|
|
<TaskChatCompactInteractionCard interaction={interaction} presentation={presentation} draftKey={draftKey} {...cardProps} />
|
|
</div>;
|
|
}
|
|
if (presentation === "timeline" && showUnansweredQuestion && !shouldHideInteractionCard(interaction) && interaction.kind === "ask_user_questions" && interaction.status === "pending") {
|
|
const prompt = interaction.payload.questionSet?.questions[0]?.prompt ?? interaction.payload.questions[0]?.prompt ?? interaction.title ?? "Question";
|
|
return (
|
|
<button
|
|
type="button"
|
|
id={`interaction-${interaction.id}`}
|
|
data-testid="task-chat-unanswered-question"
|
|
aria-label={`Answer question: ${prompt}`}
|
|
disabled={!onReviewRequest}
|
|
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">Unanswered question</span><span className="block truncate">{markdownToPlainText(prompt)}</span></span>
|
|
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
|
</button>
|
|
);
|
|
}
|
|
if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) {
|
|
const action = interaction.payload.toolAction;
|
|
if (presentation === "takeover") {
|
|
return <IssueThreadInteractionCard interaction={interaction} {...cardProps} />;
|
|
}
|
|
return (
|
|
<div id={`interaction-${interaction.id}`} data-testid="task-chat-tool-review" className="w-full space-y-2">
|
|
{interaction.status === "pending" ? (
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<AppLogo name={action.appDisplayName || action.toolDisplayName} size={36} />
|
|
<div className="min-w-0 flex-1"><MarkdownBody>{action.previewMarkdown.split(/\n\s*\n/)[0] || action.toolDisplayName}</MarkdownBody></div>
|
|
<Button className="ml-auto" size="sm" variant="outline" disabled={!onReviewRequest} onClick={() => onReviewRequest?.(interaction.id)}>Review request</Button>
|
|
</div>
|
|
) : (
|
|
<IssueThreadInteractionCard interaction={interaction} {...cardProps} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
if (presentation === "timeline" && showPendingRequest && interaction.status === "pending"
|
|
&& 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
|
|
&& (planDocument.latestRevisionId
|
|
? interaction.payload.target.revisionId === planDocument.latestRevisionId
|
|
: interaction.payload.target.revisionNumber === planDocument.latestRevisionNumber);
|
|
return (
|
|
<div className="w-full space-y-2">
|
|
{showPlan ? <TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument }} testId="plan-review-preview" /> : null}
|
|
<button type="button" id={`interaction-${interaction.id}`} data-testid="task-chat-pending-request"
|
|
aria-label={`Open request: ${label}`} disabled={!onReviewRequest}
|
|
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">{previewText}</span></span>
|
|
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
const isSupersededQuestionReceipt =
|
|
interaction.kind === "ask_user_questions" &&
|
|
interaction.status !== "pending" &&
|
|
interaction.result?.expirationReason === "superseded_by_newer_interaction";
|
|
if (shouldHideInteractionCard(interaction) && !isSupersededQuestionReceipt)
|
|
return null;
|
|
if (presentation === "timeline" && interaction.status === "pending" && interaction.kind !== "connection_intent") {
|
|
const isPlanReview =
|
|
interaction.kind === "request_confirmation" &&
|
|
Boolean(interaction.sourceRunId) &&
|
|
interaction.payload.target?.type === "issue_document" &&
|
|
interaction.payload.target.key === "plan" &&
|
|
Boolean(planDocument) &&
|
|
(planDocument?.latestRevisionId
|
|
? interaction.payload.target.revisionId ===
|
|
planDocument.latestRevisionId
|
|
: interaction.payload.target.revisionNumber ===
|
|
planDocument?.latestRevisionNumber);
|
|
if (!isPlanReview || !showPlanPreview) return null;
|
|
return (
|
|
<div
|
|
id={`interaction-${interaction.id}`}
|
|
data-testid="task-chat-interaction"
|
|
>
|
|
<TaskChatPlanPreviewCard
|
|
source={{ kind: "saved", document: planDocument }}
|
|
testId="plan-review-preview"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
const isResolvedPlanReview =
|
|
presentation === "timeline" &&
|
|
interaction.kind === "request_confirmation" &&
|
|
(interaction.status === "accepted" || interaction.status === "rejected") &&
|
|
interaction.payload.target?.type === "issue_document" &&
|
|
interaction.payload.target.key === "plan";
|
|
const resolvedPlanMatchesDocument =
|
|
isResolvedPlanReview &&
|
|
interaction.kind === "request_confirmation" && interaction.payload.target?.type === "issue_document" &&
|
|
planDocument != null &&
|
|
(planDocument.latestRevisionId
|
|
? interaction.payload.target?.revisionId === planDocument.latestRevisionId
|
|
: interaction.payload.target?.revisionNumber ===
|
|
planDocument.latestRevisionNumber);
|
|
if (isResolvedPlanReview && !coordinatorEnabled) {
|
|
// Keep the decision visible: the approved or sent-back plan stays as a
|
|
// card while it is current, and the receipt records who decided and why.
|
|
return (
|
|
<div
|
|
id={`interaction-${interaction.id}`}
|
|
className="w-full space-y-2"
|
|
data-testid="task-chat-interaction"
|
|
>
|
|
{resolvedPlanMatchesDocument && showPlanPreview && planDocument ? (
|
|
<TaskChatPlanPreviewCard
|
|
source={{ kind: "saved", document: planDocument }}
|
|
testId="plan-review-preview"
|
|
/>
|
|
) : null}
|
|
<TaskChatCompactInteractionCard
|
|
interaction={interaction}
|
|
planDocument={planDocument}
|
|
showPlanPreview={false}
|
|
presentation="timeline"
|
|
draftKey={draftKey}
|
|
{...cardProps}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (presentation === "timeline" && interaction.status !== "pending") {
|
|
return (
|
|
<div
|
|
id={`interaction-${interaction.id}`}
|
|
className="tc-enter-bubble w-full"
|
|
data-testid="task-chat-interaction"
|
|
>
|
|
<TaskChatCompactInteractionCard
|
|
interaction={interaction}
|
|
planDocument={planDocument}
|
|
showPlanPreview={showPlanPreview}
|
|
presentation="timeline"
|
|
draftKey={draftKey}
|
|
{...cardProps}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
id={`interaction-${interaction.id}`}
|
|
className="tc-enter-bubble w-full"
|
|
data-testid="task-chat-interaction"
|
|
>
|
|
<TaskChatCompactInteractionCard
|
|
interaction={interaction}
|
|
planDocument={planDocument}
|
|
showPlanPreview={showPlanPreview}
|
|
presentation={presentation}
|
|
draftKey={draftKey}
|
|
{...cardProps}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|