From fb9ee74d0109fb1dcde09a11b419f201bc5225f4 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 14:07:32 +0000 Subject: [PATCH] fix(canvas): render markdown + clamp long bodies in Tasks/Approvals cards - Reuse react-markdown + remark-gfm (same stack as chat) for task/approval card bodies. - Escape raw HTML by default (no XSS surface). - Clamp long bodies to ~4 lines with Show more / Show less toggle. - Add typography polish: line-height, inline code chips, list indentation, divider, dim meta row. - Add tests for markdown rendering, lists/code blocks, and truncation toggle. Relates molecule-core#2939 follow-up (CTO UI polish). Co-Authored-By: Claude --- .../components/concierge/Concierge.module.css | 55 +++++++++++++++++ .../components/concierge/RequestsInbox.tsx | 61 +++++++++++++++++-- .../__tests__/RequestsInbox.test.tsx | 50 +++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) 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")]); -- 2.52.0