fix(canvas): render markdown and clamp long bodies in Tasks/Approvals cards #2941

Merged
devops-engineer merged 1 commits from fix/canvas-requests-markdown into main 2026-06-15 14:15:55 +00:00
3 changed files with 162 additions and 4 deletions
@@ -214,6 +214,61 @@
.btn.deny:hover { background: rgba(239,68,68,.14); color: var(--red); border-color: rgba(239,68,68,.3); }
.btn.flex { flex: 1; text-align: center; }
/* ===== REQUEST CARD BODY (shared by Tasks + Approvals) ===== */
.reqBodyWrap { margin-top: 8px; }
.reqDivider { border: none; border-top: 1px solid var(--hair); margin: 0 0 8px; }
.reqBody { font-size: 12px; line-height: 1.5; color: var(--tx-2); }
.reqBody :first-child { margin-top: 0; }
.reqBody :last-child { margin-bottom: 0; }
.reqBody p { margin: 0 0 6px; }
.reqBody h1,
.reqBody h2,
.reqBody h3,
.reqBody h4,
.reqBody h5,
.reqBody h6 { font-size: 12.5px; font-weight: 600; color: var(--tx); margin: 8px 0 4px; }
.reqBody ul,
.reqBody ol { padding-left: 18px; margin: 4px 0; }
.reqBody li { margin: 2px 0; }
.reqBody code {
font-family: var(--mono);
font-size: 11px;
color: var(--tx);
background: var(--hair);
padding: 1px 5px;
border-radius: 5px;
word-break: break-word;
}
.reqBody pre {
background: var(--panel);
border: 1px solid var(--hair);
border-radius: 8px;
padding: 8px 10px;
margin: 6px 0;
overflow-x: auto;
}
.reqBody pre code { background: transparent; padding: 0; font-size: 11px; }
.reqBody a { color: var(--accent-2); text-decoration: underline; }
.reqBody a:hover { color: var(--accent); }
.reqBodyClamped {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reqShowMore {
display: inline-flex;
margin-top: 6px;
padding: 0;
border: none;
background: none;
color: var(--accent-2);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
}
.reqShowMore:hover { color: var(--accent); }
/* ===== CHAT ===== */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.chatHead { height: 56px; flex: 0 0 56px; border-bottom: 1px solid var(--hair); display: flex; align-items: center; gap: 12px; padding: 0 22px; background: var(--panel-2); }
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { fetchSession } from "@/lib/auth";
import { showToast } from "@/components/Toaster";
@@ -331,7 +333,7 @@ function RequestItem({
<span data-testid="request-status">{badge}</span>
{" · asked "}{clockTime(row.created_at)}
</div>
{row.detail && <div className={s.apprS} style={{ marginTop: 4 }}>{row.detail}</div>}
{row.detail && <RequestBody body={row.detail} />}
{resolvedBy && <div className={s.apprS} style={{ marginTop: 4 }}>{resolvedBy}</div>}
</div>
</div>
@@ -355,9 +357,7 @@ function RequestItem({
{" · asked "}{clockTime(row.created_at)}
</div>
{row.detail && (
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6, lineHeight: 1.45 }}>
{row.detail}
</div>
<RequestBody body={row.detail} />
)}
{resolvedBy && (
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6 }}>{resolvedBy}</div>
@@ -372,6 +372,59 @@ function RequestItem({
);
}
/** Approximate characters that fit in one line inside the 296 px sidebar card. */
const BODY_CHARS_PER_LINE = 48;
const BODY_MAX_LINES = 4;
function bodyNeedsClamp(body: string): boolean {
const lines = body.split("\n").length;
return lines > BODY_MAX_LINES || body.length > BODY_CHARS_PER_LINE * BODY_MAX_LINES;
}
/** Open links from agent-authored markdown in a new tab so the canvas stays put. */
function SafeLink({ href, children, ...rest }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...rest}>
{children}
</a>
);
}
/** Shared markdown body for Task + Approval cards.
*
* - Renders markdown (bold, inline/fenced code, lists, headers, links) using the
* same react-markdown + remark-gfm stack as chat messages.
* - HTML is escaped by default (no raw HTML / XSS).
* - Long bodies are clamped to ~4 lines with a Show more / Show less toggle.
*/
function RequestBody({ body }: { body: string | null | undefined }) {
const [expanded, setExpanded] = useState(false);
if (!body) return null;
const clamp = !expanded && bodyNeedsClamp(body);
return (
<div className={s.reqBodyWrap} data-testid="request-body">
<hr className={s.reqDivider} />
<div className={`${s.reqBody} ${clamp ? s.reqBodyClamped : ""}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: SafeLink }}>
{body}
</ReactMarkdown>
</div>
{bodyNeedsClamp(body) && (
<button
type="button"
className={s.reqShowMore}
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
interface MoreInfoThreadProps {
requestId: string;
responderId: string;
@@ -185,6 +185,56 @@ describe("RequestsInbox — More Info thread", () => {
});
});
describe("RequestsInbox — card body markdown + truncation", () => {
it("renders markdown formatting instead of raw literals", async () => {
mockApiGet.mockResolvedValue([
{ ...taskRow("t1"), detail: "**bold** and `code` and [link](https://example.com)" },
]);
const { container } = await act(async () => render(<RequestsInbox kind="task" />));
expect(container.querySelector("strong")?.textContent).toBe("bold");
expect(container.querySelector("code")?.textContent).toBe("code");
const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toBe("https://example.com");
expect(link?.getAttribute("target")).toBe("_blank");
expect(screen.queryByText("\*\*bold\*\*")).toBeNull();
});
it("renders lists and fenced code blocks", async () => {
mockApiGet.mockResolvedValue([
{
...taskRow("t1"),
detail: "- one\n- two\n\n```\nhello\n```",
},
]);
const { container } = await act(async () => render(<RequestsInbox kind="task" />));
expect(container.querySelectorAll("li").length).toBe(2);
expect(container.querySelector("pre")?.textContent).toContain("hello");
});
it("shows a Show more / Show less toggle for long bodies", async () => {
const longDetail = "Line one.\nLine two.\nLine three.\nLine four.\nLine five.\nLine six.";
mockApiGet.mockResolvedValue([{ ...taskRow("t1"), detail: longDetail }]);
await act(async () => { render(<RequestsInbox kind="task" />); });
const toggle = screen.getByRole("button", { name: /show more/i });
expect(toggle).toBeTruthy();
expect(toggle.getAttribute("aria-expanded")).toBe("false");
await act(async () => { fireEvent.click(toggle); });
expect(screen.getByRole("button", { name: /show less/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /show less/i }).getAttribute("aria-expanded")).toBe("true");
});
it("does not show a toggle for short bodies", async () => {
mockApiGet.mockResolvedValue([taskRow("t1")]);
await act(async () => { render(<RequestsInbox kind="task" />); });
expect(screen.queryByRole("button", { name: /show more/i })).toBeNull();
});
});
describe("RequestsInbox — tab-switch wrong-action race (core#2766)", () => {
it("clears stale rows immediately on kind switch before the new fetch resolves", async () => {
mockApiGet.mockResolvedValue([approvalRow("a1")]);