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 ? (