diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index c970f8980..b56b1bad5 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -93,7 +93,7 @@ File-by-file ownership and coverage pointers: [README.md](README.md). - Conversation font size is a bounded integer pixel value. Scale body, headings, dense monospace, terminal output, and collapsed height through `conversation-font-size-classes.ts`; settings own legacy preset migration. - Keep Streamdown in streaming mode, but never enable word-level `animated`. + Streamdown mode is streaming while growing or a fence is open; never word-level `animated`. - A Mermaid diagram in a message is a still preview until a pointer click activates it, and an unmodified wheel is NEVER taken — activated or not. `mermaid-diagram-viewer.tsx` stays the only full-screen surface, reached from diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index 4b9b8f8ba..62dba1db9 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -56,6 +56,7 @@ import { useResolvedTheme } from '../../theme-provider'; import type { ConversationFontSize } from '@/atoms/settings'; import { useTaskImageUrl } from '@/hooks/use-task-image'; import { MarkdownDiffBlock } from './markdown-diff-block'; +import { resolveMarkdownStreamdownMode } from './markdown-streamdown-mode'; import { createMarkdownMermaidConfig, createMarkdownMermaidPlugin } from './markdown-mermaid'; import { MermaidDiagramViewer } from './mermaid-diagram-viewer'; import { MermaidFullscreenButton, useMermaidDiagramCanvas } from './use-mermaid-diagram-canvas'; @@ -1313,7 +1314,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ // remount when raw-HTML mode or Mermaid theme changes so sanitized // rendering and diagram colors update correctly. key={streamdownKey} - mode="streaming" + mode={resolveMarkdownStreamdownMode(isStreaming, normalizedText)} className="space-y-0" controls={STREAMDOWN_CONTROLS} isAnimating={isStreaming} diff --git a/packages/components/src/components/ai-gui/markdown-streamdown-mode.ts b/packages/components/src/components/ai-gui/markdown-streamdown-mode.ts new file mode 100644 index 000000000..31013594d --- /dev/null +++ b/packages/components/src/components/ai-gui/markdown-streamdown-mode.ts @@ -0,0 +1,11 @@ +import { markdownHasUnclosedFence } from '@/lib/markdown-single-dollar-math'; + +export { markdownHasUnclosedFence }; + +export function resolveMarkdownStreamdownMode( + isStreaming: boolean, + text = '' +): 'static' | 'streaming' { + if (isStreaming || markdownHasUnclosedFence(text)) return 'streaming'; + return 'static'; +} diff --git a/packages/components/src/lib/markdown-single-dollar-math.ts b/packages/components/src/lib/markdown-single-dollar-math.ts index ade3457fb..3ec08630f 100644 --- a/packages/components/src/lib/markdown-single-dollar-math.ts +++ b/packages/components/src/lib/markdown-single-dollar-math.ts @@ -197,6 +197,38 @@ const fencedCodeEnd = (value: string, lineStart: number, fence: MarkdownFence): return value.length; }; +export const markdownHasUnclosedFence = (value: string): boolean => { + let lineStart = 0; + + while (lineStart < value.length) { + const fence = markdownFenceAt(value, lineStart); + const nextLine = lineEndAfter(value, lineStart); + if (!fence) { + if (nextLine <= lineStart) break; + lineStart = nextLine; + continue; + } + + let cursor = nextLine; + let closed = false; + while (cursor < value.length) { + if (isClosingMarkdownFence(value, cursor, fence)) { + closed = true; + cursor = lineEndAfter(value, cursor); + break; + } + const after = lineEndAfter(value, cursor); + if (after <= cursor) break; + cursor = after; + } + if (!closed) return true; + if (cursor <= lineStart) break; + lineStart = cursor; + } + + return false; +}; + const backtickRunLength = (value: string, start: number): number => { let cursor = start; while (cursor < value.length && value[cursor] === '`') cursor += 1; diff --git a/packages/components/tests/markdown-streamdown-mode.test.ts b/packages/components/tests/markdown-streamdown-mode.test.ts new file mode 100644 index 000000000..79162fabe --- /dev/null +++ b/packages/components/tests/markdown-streamdown-mode.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveMarkdownStreamdownMode } from '../src/components/ai-gui/markdown-streamdown-mode'; + +describe('resolveMarkdownStreamdownMode', () => { + it('uses static mode for finished complete markdown', () => { + expect(resolveMarkdownStreamdownMode(false, 'Hello **world**.')).toBe('static'); + }); + + it('uses streaming mode while a turn is still growing', () => { + expect(resolveMarkdownStreamdownMode(true, 'Hello **wor')).toBe('streaming'); + }); + + it('keeps streaming mode for a finished turn with an unclosed fence', () => { + expect(resolveMarkdownStreamdownMode(false, '```ts\nconst x = 1;\n')).toBe('streaming'); + }); + + it('keeps streaming mode for an unclosed indented backtick fence', () => { + expect(resolveMarkdownStreamdownMode(false, '1. example:\n ```ts\n const x = 1;\n')).toBe( + 'streaming' + ); + }); + + it('keeps streaming mode for an unclosed tilde fence', () => { + expect(resolveMarkdownStreamdownMode(false, '~~~js\nconst x = 1;\n')).toBe('streaming'); + }); + + it('uses static mode when an indented fence is closed', () => { + expect( + resolveMarkdownStreamdownMode(false, '1. example:\n ```ts\n const x = 1;\n ```\n') + ).toBe('static'); + }); + + it('does not treat a 4-space indent as a fence', () => { + expect(resolveMarkdownStreamdownMode(false, ' ```ts\n const x = 1;\n')).toBe('static'); + }); + + it('keeps streaming mode when a shorter closer cannot end a longer fence', () => { + expect(resolveMarkdownStreamdownMode(false, '````ts\nconst x = 1;\n```\n')).toBe('streaming'); + }); + + it('keeps streaming mode for an unclosed blockquote fence', () => { + expect(resolveMarkdownStreamdownMode(false, '> ```tex\n> \\(x\\)\n')).toBe('streaming'); + }); + + it('keeps streaming mode for an unclosed ordered-list fence', () => { + expect(resolveMarkdownStreamdownMode(false, '10. ```tex\n \\(x\\)\n')).toBe('streaming'); + }); + + it('uses static mode when a nested list fence is closed', () => { + expect(resolveMarkdownStreamdownMode(false, '- ~~~tex\n \\[x\\]\n ~~~\n')).toBe('static'); + }); +});