authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-19 12:05:49+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-19 14:35:49+05:30
logdfbb11bdd7c3f1a5575cb55d3e3abb12be025727
tree513cc610c70728ea3234208ec5e95249c51a132c
parent408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23
signature Signed by PGP key B5690EEEBB952194

fix(web): show plain text in collapsed thought previews (#12377)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

2 files changed, 124 insertions(+), 1 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx+92
...@@ -1656,6 +1656,98 @@ describe("MessagesTimeline", () => {...@@ -1656,6 +1656,98 @@ describe("MessagesTimeline", () => {
1656 expect(markup).not.toContain("tool call failed");1656 expect(markup).not.toContain("tool call failed");
1657 });1657 });
16581658
1659 it.each(
1660 (
1661 [
1662 [
1663 "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)",
1664 "Viewing image first with care, old code and context",
1665 1,
1666 ],
1667 ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0],
1668 ["- first\n- second", "first second", 0],
1669 ["first \nsecond", "first second", 0],
1670 ["![image description](image.png)", "image description", 0],
1671 ["![](image.png)", "Thought", 0],
1672 ["---", "Thought", 0],
1673 ] as const
1674 ).flatMap(([markdown, expected, strongCount]) =>
1675 [false, true].map((streaming) => ({
1676 markdown,
1677 expected,
1678 strongCount,
1679 streaming,
1680 })),
1681 ),
1682 )(
1683 "shows a plain thought preview for $markdown, streaming=$streaming",
1684 async ({ markdown, expected, strongCount, streaming }) => {
1685 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1686 vi.stubGlobal("requestAnimationFrame", () => 0);
1687 vi.stubGlobal("cancelAnimationFrame", () => {});
1688 const turnId = TurnId.make("turn-thought");
1689 const thought = buildAssistantTimelineEntry(markdown);
1690 let renderer: ReactTestRenderer | undefined;
1691 try {
1692 await act(() => {
1693 renderer = create(
1694 <MessagesTimeline
1695 {...buildProps()}
1696 isWorking
1697 runningTurnId={turnId}
1698 timelineEntries={[
1699 {
1700 id: "work-entry",
1701 kind: "work",
1702 createdAt: MESSAGE_CREATED_AT,
1703 entry: {
1704 id: "work",
1705 createdAt: MESSAGE_CREATED_AT,
1706 turnId,
1707 label: "Read image",
1708 tone: "tool",
1709 itemType: "command_execution",
1710 command: "cat image.png",
1711 toolLifecycleStatus: "completed",
1712 },
1713 },
1714 {
1715 ...thought,
1716 message: { ...thought.message, role: "reasoning", turnId, streaming },
1717 },
1718 ]}
1719 />,
1720 );
1721 });
1722 await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick());
1723 const text = renderer!.root.findByProps({
1724 className: "relative min-w-0 flex-1 truncate text-secondary-label",
1725 });
1726 const preview = text.parent!;
1727 expect(
1728 text
1729 .findAll(() => true)
1730 .flatMap((node) => node.children)
1731 .filter((child) => typeof child === "string")
1732 .join(""),
1733 ).toBe(
1734 (streaming && expected === "Thought" ? "Thinking" : expected).repeat(streaming ? 2 : 1),
1735 );
1736 expect(
1737 preview.findAll((node) =>
1738 ["strong", "em", "del", "code", "a"].includes(String(node.type)),
1739 ),
1740 ).toHaveLength(0);
1741 await act(() => preview.props.onClick());
1742 expect(renderer!.root.findAllByType("strong")).toHaveLength(strongCount);
1743 await act(() => preview.props.onClick());
1744 expect(renderer!.root.findAllByType("strong")).toHaveLength(0);
1745 } finally {
1746 await act(() => renderer?.unmount());
1747 }
1748 },
1749 );
1750
1659 it("renders initial thinking as the shared live activity row", () => {1751 it("renders initial thinking as the shared live activity row", () => {
1660 const turnId = TurnId.make("turn-live");1752 const turnId = TurnId.make("turn-live");
1661 const markup = renderToStaticMarkup(1753 const markup = renderToStaticMarkup(
apps/web/src/components/chat/MessagesTimeline.tsx+32-1
...@@ -103,6 +103,9 @@ import {...@@ -103,6 +103,9 @@ import {
103} from "../../lib/diffRendering";103} from "../../lib/diffRendering";
104import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";104import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
105import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";105import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
106import ReactMarkdown from "react-markdown";
107import remarkGfm from "remark-gfm";
108import type { Root, RootContent } from "mdast";
106import { T3Wordmark } from "../T3Wordmark";109import { T3Wordmark } from "../T3Wordmark";
107import {110import {
108 BotIcon,111 BotIcon,
...@@ -2689,6 +2692,28 @@ function ThinkingTimelineRow() {...@@ -2689,6 +2692,28 @@ function ThinkingTimelineRow() {
2689 );2692 );
2690}2693}
26912694
2695function remarkThoughtPreview(fallback: string) {
2696 return (tree: Root) => {
2697 const plainText = (node: Root | RootContent): string => {
2698 if (node.type === "html" || node.type === "definition") return "";
2699 if ("alt" in node) return node.alt ?? "";
2700 if ("value" in node) return node.value;
2701 if ("children" in node) {
2702 const separator = ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes(
2703 node.type,
2704 )
2705 ? " "
2706 : "";
2707 return node.children.map(plainText).join(separator);
2708 }
2709 return node.type === "break" ? " " : "";
2710 };
2711 tree.children = [
2712 { type: "text", value: plainText(tree).replace(/\s+/g, " ").trim() || fallback },
2713 ];
2714 };
2715}
2716
2692/**2717/**
2693 * Thinking inside a tool group has its own disclosure, preserved across recycling.2718 * Thinking inside a tool group has its own disclosure, preserved across recycling.
2694 * A group whose row already reads "Thought" (no visible tool) skips the header.2719 * A group whose row already reads "Thought" (no visible tool) skips the header.
...@@ -2722,7 +2747,13 @@ function ReasoningTraceBlock({...@@ -2722,7 +2747,13 @@ function ReasoningTraceBlock({
2722 }2747 }
2723 const label = streaming ? "Thinking" : "Thought";2748 const label = streaming ? "Thinking" : "Thought";
2724 const collapsedPreview = messages.find((message) => message.text.trim().length > 0)?.text.trim();2749 const collapsedPreview = messages.find((message) => message.text.trim().length > 0)?.text.trim();
2725 const headerText = expanded ? label : (collapsedPreview ?? label);2750 const headerText = expanded ? (
2751 label
2752 ) : (
2753 <ReactMarkdown remarkPlugins={[remarkGfm, [remarkThoughtPreview, label]]}>
2754 {collapsedPreview ?? label}
2755 </ReactMarkdown>
2756 );
2726 return (2757 return (
2727 <div className="flex flex-col">2758 <div className="flex flex-col">
2728 {showHeader ? (2759 {showHeader ? (