diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3bf5f482948dd9f31fe9bb3e5486c92373e792ef..dae27ccdba473134395d2b7a89a7c13d74f5ff37 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1656,6 +1656,98 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); + it.each( + ( + [ + [ + "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)", + "Viewing image first with care, old code and context", + 1, + ], + ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0], + ["- first\n- second", "first second", 0], + ["first \nsecond", "first second", 0], + ["![image description](image.png)", "image description", 0], + ["![](image.png)", "Thought", 0], + ["---", "Thought", 0], + ] as const + ).flatMap(([markdown, expected, strongCount]) => + [false, true].map((streaming) => ({ + markdown, + expected, + strongCount, + streaming, + })), + ), + )( + "shows a plain thought preview for $markdown, streaming=$streaming", + async ({ markdown, expected, strongCount, streaming }) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const turnId = TurnId.make("turn-thought"); + const thought = buildAssistantTimelineEntry(markdown); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick()); + const text = renderer!.root.findByProps({ + className: "relative min-w-0 flex-1 truncate text-secondary-label", + }); + const preview = text.parent!; + expect( + text + .findAll(() => true) + .flatMap((node) => node.children) + .filter((child) => typeof child === "string") + .join(""), + ).toBe( + (streaming && expected === "Thought" ? "Thinking" : expected).repeat(streaming ? 2 : 1), + ); + expect( + preview.findAll((node) => + ["strong", "em", "del", "code", "a"].includes(String(node.type)), + ), + ).toHaveLength(0); + await act(() => preview.props.onClick()); + expect(renderer!.root.findAllByType("strong")).toHaveLength(strongCount); + await act(() => preview.props.onClick()); + expect(renderer!.root.findAllByType("strong")).toHaveLength(0); + } finally { + await act(() => renderer?.unmount()); + } + }, + ); + it("renders initial thinking as the shared live activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8305cb1d5283ba8aad221e73ba83ab43dad65eab..42a45b9910281f72c479bdd9ed358cdbd53ff342 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -103,6 +103,9 @@ import { } from "../../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { Root, RootContent } from "mdast"; import { T3Wordmark } from "../T3Wordmark"; import { BotIcon, @@ -2689,6 +2692,28 @@ function ThinkingTimelineRow() { ); } +function remarkThoughtPreview(fallback: string) { + return (tree: Root) => { + const plainText = (node: Root | RootContent): string => { + if (node.type === "html" || node.type === "definition") return ""; + if ("alt" in node) return node.alt ?? ""; + if ("value" in node) return node.value; + if ("children" in node) { + const separator = ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes( + node.type, + ) + ? " " + : ""; + return node.children.map(plainText).join(separator); + } + return node.type === "break" ? " " : ""; + }; + tree.children = [ + { type: "text", value: plainText(tree).replace(/\s+/g, " ").trim() || fallback }, + ]; + }; +} + /** * Thinking inside a tool group has its own disclosure, preserved across recycling. * A group whose row already reads "Thought" (no visible tool) skips the header. @@ -2722,7 +2747,13 @@ function ReasoningTraceBlock({ } const label = streaming ? "Thinking" : "Thought"; const collapsedPreview = messages.find((message) => message.text.trim().length > 0)?.text.trim(); - const headerText = expanded ? label : (collapsedPreview ?? label); + const headerText = expanded ? ( + label + ) : ( + + {collapsedPreview ?? label} + + ); return (
{showHeader ? (