fix(canvas): render markdown and clamp long bodies in Tasks/Approvals cards #2941
@@ -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")]);
|
||||
|
||||
Reference in New Issue
Block a user