diff --git a/canvas/src/components/concierge/Concierge.module.css b/canvas/src/components/concierge/Concierge.module.css
index 1fff16f19..1869795e0 100644
--- a/canvas/src/components/concierge/Concierge.module.css
+++ b/canvas/src/components/concierge/Concierge.module.css
@@ -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); }
diff --git a/canvas/src/components/concierge/RequestsInbox.tsx b/canvas/src/components/concierge/RequestsInbox.tsx
index 76a344bf2..9ae8d9b40 100644
--- a/canvas/src/components/concierge/RequestsInbox.tsx
+++ b/canvas/src/components/concierge/RequestsInbox.tsx
@@ -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({
{badge}
{" · asked "}{clockTime(row.created_at)}
- {row.detail &&
{row.detail}
}
+ {row.detail && }
{resolvedBy && {resolvedBy}
}
@@ -355,9 +357,7 @@ function RequestItem({
{" · asked "}{clockTime(row.created_at)}
{row.detail && (
-
- {row.detail}
-
+
)}
{resolvedBy && (
{resolvedBy}
@@ -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) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** 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 (
+
+
+
+
+ {body}
+
+
+ {bodyNeedsClamp(body) && (
+
+ )}
+
+ );
+}
+
interface MoreInfoThreadProps {
requestId: string;
responderId: string;
diff --git a/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx b/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx
index 8ed06260a..3bf8e727b 100644
--- a/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx
+++ b/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx
@@ -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());
+
+ 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());
+
+ 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(); });
+
+ 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(); });
+ 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")]);