Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/components/ai-elements/message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ vi.mock("streamdown", () => ({
Streamdown: ({
children,
className,
mode,
parseIncompleteMarkdown,
}: {
children: ReactNode
className?: string
mode?: string
parseIncompleteMarkdown?: boolean
}) => (
<div className={className} data-testid="streamdown-root">
<div
className={className}
data-testid="streamdown-root"
data-mode={mode}
data-parse-incomplete={String(parseIncompleteMarkdown)}
>
{children}
</div>
),
Expand Down Expand Up @@ -45,4 +54,26 @@ describe("MessageResponse", () => {
"[&_ol]:pl-3"
)
})

it("keeps finished replies in static mode so remend cannot append leftover * / _", () => {
render(
<MessageResponse>{"see `tools/dsv4-0731-c1/*` please."}</MessageResponse>
)

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(
<MessageResponse mode="streaming" parseIncompleteMarkdown>
{"see `tools/dsv4-0731-c1/*` please."}
</MessageResponse>
)

const root = screen.getByTestId("streamdown-root")
expect(root).toHaveAttribute("data-mode", "streaming")
expect(root).toHaveAttribute("data-parse-incomplete", "true")
})
})
13 changes: 12 additions & 1 deletion src/components/ai-elements/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,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(
Expand All @@ -403,6 +408,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 }}
Expand All @@ -414,7 +421,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"
Expand Down
2 changes: 2 additions & 0 deletions src/components/ai-elements/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,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}
>
Expand Down
2 changes: 2 additions & 0 deletions src/components/files/file-workspace-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ function MarkdownDocumentPreview({
<div className="h-full overflow-auto p-6 [&_a_img]:inline [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-6 [&_ul]:pl-6">
<Streamdown
plugins={plugins}
mode="static"
parseIncompleteMarkdown={false}
components={{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
img: ({ node, ...imgProps }) => (
Expand Down
12 changes: 11 additions & 1 deletion src/components/message/content-parts-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2213,11 +2213,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 (
Expand All @@ -2228,7 +2230,12 @@ const TextPart = memo(function TextPart({
}
return (
<div className='break-words text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside [&_[data-streamdown="code-block-body"]]:max-h-96 [&_[data-streamdown="code-block-body"]]:overflow-auto'>
<MessageResponse>{text}</MessageResponse>
<MessageResponse
mode={isStreaming ? "streaming" : "static"}
parseIncompleteMarkdown={isStreaming}
>
{text}
</MessageResponse>
</div>
)
})
Expand Down Expand Up @@ -2994,11 +3001,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") {
Expand All @@ -3007,6 +3016,7 @@ export const ContentPartsRenderer = memo(function ContentPartsRenderer({
key={`text-${keyId}`}
text={part.text}
isUser={role === "user"}
isStreaming={isStreaming}
/>
)
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/message/message-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,11 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
</div>
) : (
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
<ContentPartsRenderer
parts={group.parts}
role={group.role}
isStreaming={!isResponseComplete}
/>
</MessageContent>
)}
{group.role === "user" && group.resources.length > 0 ? (
Expand Down
Loading