diff --git a/site/src/components/ai-elements/response.stories.tsx b/site/src/components/ai-elements/response.stories.tsx index 5a2392567c..50ec73650e 100644 --- a/site/src/components/ai-elements/response.stories.tsx +++ b/site/src/components/ai-elements/response.stories.tsx @@ -103,3 +103,39 @@ export const JsxInProse: Story = { expect(marker2).toBeInTheDocument(); }, }; + +// Verifies that streaming mode closes incomplete inline markdown via +// remend so the user never sees raw syntax during the reveal animation. +export const StreamingInlineMarkdown: Story = { + args: { + children: "This is **bold text that has not been close", + streaming: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // remend should close the unclosed ** so the text renders + // as , not as a raw "**" literal. + const el = await canvas.findByText(/bold text/); + expect(el).toBeInTheDocument(); + // The raw double-asterisk should not appear as visible text. + const bodyText = canvasElement.textContent ?? ""; + expect(bodyText).not.toContain("**"); + }, +}; + +// Verifies that an incomplete fenced code block in streaming mode +// renders inside a code element rather than showing raw backticks. +export const StreamingCodeFence: Story = { + args: { + children: "```ts\nconst x = 1", + streaming: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const codeContent = await canvas.findByText(/const x = 1/); + expect(codeContent).toBeInTheDocument(); + // The raw triple-backtick should not appear as visible text. + const bodyText = canvasElement.textContent ?? ""; + expect(bodyText).not.toContain("```"); + }, +}; diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx index 2709cdccde..7a8fd2f22e 100644 --- a/site/src/components/ai-elements/response.tsx +++ b/site/src/components/ai-elements/response.tsx @@ -15,6 +15,10 @@ import { cn } from "utils/cn"; interface ResponseProps extends Omit, "children"> { children: string; urlTransform?: UrlTransform; + /** Enable streaming-mode Streamdown with incomplete-markdown + * preprocessing (remend) and useTransition-based render + * scheduling. Pass true only for live-streaming output. */ + streaming?: boolean; } // Omit rehype-raw so HTML-like syntax in LLM output is rendered as @@ -231,6 +235,7 @@ export const Response = ({ children, ref, urlTransform, + streaming, ...props }: ResponseProps) => { const theme = useTheme(); @@ -253,6 +258,8 @@ export const Response = ({ components={components} urlTransform={urlTransform} rehypePlugins={chatRehypePlugins} + mode={streaming ? "streaming" : "static"} + parseIncompleteMarkdown={streaming} > {children} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 114c016281..b560f5433b 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -69,6 +69,7 @@ const ReasoningDisclosure: FC<{ {displayText} @@ -117,7 +118,11 @@ const SmoothedResponse: FC<{ bypassSmoothing: false, streamKey, }); - return {visibleText}; + return ( + + {visibleText} + + ); }; const InlineTextAttachmentButton: FC<{