diff --git a/src/components/ai-elements/message.test.tsx b/src/components/ai-elements/message.test.tsx index f79077786..e9ea7c014 100644 --- a/src/components/ai-elements/message.test.tsx +++ b/src/components/ai-elements/message.test.tsx @@ -6,11 +6,20 @@ vi.mock("streamdown", () => ({ Streamdown: ({ children, className, + mode, + parseIncompleteMarkdown, }: { children: ReactNode className?: string + mode?: string + parseIncompleteMarkdown?: boolean }) => ( -
+
{children}
), @@ -46,6 +55,28 @@ describe("MessageResponse", () => { ) }) + it("keeps finished replies in static mode so remend cannot append leftover * / _", () => { + render( + {"see `tools/dsv4-0731-c1/*` please."} + ) + + const root = screen.getByTestId("streamdown-root") + expect(root).toHaveAttribute("data-mode", "static") + expect(root).toHaveAttribute("data-parse-incomplete", "false") + }) + + it("opts the live stream back into remend", () => { + render( + + {"see `tools/dsv4-0731-c1/*` please."} + + ) + + const root = screen.getByTestId("streamdown-root") + expect(root).toHaveAttribute("data-mode", "streaming") + expect(root).toHaveAttribute("data-parse-incomplete", "true") + }) + it("re-declares the blockquote rule Streamdown's own classes lose", () => { // Streamdown sets `border-l-4 border-muted-foreground/30 … italic` on // blockquotes from inside node_modules, which Tailwind v4 does not scan — so diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 9e11faba8..521ffa46c 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -478,6 +478,11 @@ const rehypePlugins = rehypePluginsAllowingCodeg(defaultRehypePlugins) function MessageResponseImpl({ className, children, + // Streamdown defaults to mode="streaming" + remend. remend 1.2.0 appends a + // leftover `*` / `_` after complete globs (`foo/*`) and `_meta` / `_blank` + // spans. Finished replies must stay static so that closer is never painted. + mode = "static", + parseIncompleteMarkdown = false, ...props }: MessageResponseProps) { const normalized = useMemo( @@ -513,6 +518,8 @@ function MessageResponseImpl({ remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} {...props} + mode={mode} + parseIncompleteMarkdown={parseIncompleteMarkdown} // Merge after spreading props so a caller can still override other // elements, but the link icon + safety routing on `a` always wins. components={{ ...props.components, ...markdownLinkComponents }} @@ -524,7 +531,11 @@ function MessageResponseImpl({ export const MessageResponse = memo( MessageResponseImpl, - (prevProps, nextProps) => prevProps.children === nextProps.children + (prevProps, nextProps) => + prevProps.children === nextProps.children && + (prevProps.mode ?? "static") === (nextProps.mode ?? "static") && + (prevProps.parseIncompleteMarkdown ?? false) === + (nextProps.parseIncompleteMarkdown ?? false) ) MessageResponse.displayName = "MessageResponse" diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index 5e9948e17..99ded979d 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -257,6 +257,8 @@ export const ReasoningContent = memo( plugins={plugins} remarkPlugins={remarkPlugins} {...props} + mode="static" + parseIncompleteMarkdown={false} // Enforce the link icon + safety override after spreading props. components={markdownLinkComponents} > diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index f57af678b..4b83b9996 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -268,6 +268,8 @@ function MarkdownDocumentPreview({
( diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 33fc644d6..03e60cc6b 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2215,11 +2215,13 @@ function parseCliExecutionEnvelope(text: string): { const TextPart = memo(function TextPart({ text, isUser = false, + isStreaming = false, }: { text: string // User messages render as plain text + inline reference badges (no Markdown), // matching the plain-text composer. Assistant / system text keeps full Markdown. isUser?: boolean + isStreaming?: boolean }) { if (isUser) { return ( @@ -2230,7 +2232,12 @@ const TextPart = memo(function TextPart({ } return (
- {text} + + {text} +
) }) @@ -3012,11 +3019,13 @@ const ToolGroupPart = memo(function ToolGroupPart({ interface ContentPartsRendererProps { parts: AdaptedContentPart[] role?: MessageRole + isStreaming?: boolean } export const ContentPartsRenderer = memo(function ContentPartsRenderer({ parts, role, + isStreaming = false, }: ContentPartsRendererProps) { const renderPart = (part: AdaptedContentPart, keyId: string): ReactNode => { if (part.type === "text") { @@ -3025,6 +3034,7 @@ export const ContentPartsRenderer = memo(function ContentPartsRenderer({ key={`text-${keyId}`} text={part.text} isUser={role === "user"} + isStreaming={isStreaming} /> ) } diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 4bc21f527..23d23315c 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -602,7 +602,11 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
) : ( - + )} {group.role === "user" && group.resources.length > 0 ? (