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
2 changes: 1 addition & 1 deletion packages/components/src/components/ai-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 opens in `mermaid-diagram-viewer.tsx`, never Streamdown's own
full-screen overlay (`controls.mermaid.fullscreen` stays off). Keep three
properties: controls padded by the `--safe-area-*` variables rather than a fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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, type MermaidDiagramSelection } from './mermaid-diagram-viewer';

Expand Down Expand Up @@ -1409,7 +1410,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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track list context across continuation lines

When a cancelled response opens a fence on a continuation line of an existing list, such as 1. explanation\n ```ts\nconst x = 1, markdownFenceAt sees four leading spaces without knowing they include the list-item indent and returns no fence. This makes the new resolver choose static mode and skip incomplete-Markdown repair even though the fence remains open. This is fresh evidence beyond the prior container-prefix comment: direct-marker forms are now handled, but ordinary continuation-line forms are still missed. Preserve list-container state while scanning.

AGENTS.md reference: packages/components/src/components/ai-gui/AGENTS.md:L96-L96

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this. Open-fence detection now uses the same per-line CommonMark scanner as normalizeTexMathDelimiters. Same-line list/blockquote markers (10. ```tex, > ```tex) and 0–3 space continuation fences already stay on Streamdown streaming. A 4-space continuation after 1. explanation is an indented code block at document scope unless we carry list indent across lines. That is a full list parser, not this #421 slice. Interrupted mid-list continuation fences are an accepted freeze-path edge case (.github/codex-review.md).

return 'static';
}
32 changes: 32 additions & 0 deletions packages/components/src/lib/markdown-single-dollar-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions packages/components/tests/markdown-streamdown-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading