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
7 changes: 6 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import { finalizeStreamingMarkdown } from "../streaming-markdown";

class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
Expand Down Expand Up @@ -238,6 +239,10 @@ function SuspenseShikiCodeBlock({
function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const renderedText = useMemo(
() => (isStreaming ? finalizeStreamingMarkdown(text) : text),
[isStreaming, text],
);
const markdownComponents = useMemo<Components>(
() => ({
a({ node: _node, href, ...props }) {
Expand Down Expand Up @@ -291,7 +296,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
return (
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
{renderedText}
</ReactMarkdown>
</div>
);
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/streaming-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";

import { finalizeStreamingMarkdown } from "./streaming-markdown";

describe("finalizeStreamingMarkdown", () => {
it("closes unfinished bold markers while streaming", () => {
expect(finalizeStreamingMarkdown("I just love **bold text")).toBe("I just love **bold text**");
});

it("closes unfinished inline code spans", () => {
expect(finalizeStreamingMarkdown("Use `bun fmt")).toBe("Use `bun fmt`");
});

it("closes unfinished fenced code blocks", () => {
expect(finalizeStreamingMarkdown("```ts\nconst value = 1;")).toBe(
"```ts\nconst value = 1;\n```",
);
});

it("preserves completed markdown without adding extra delimiters", () => {
expect(finalizeStreamingMarkdown("Already **done** and `closed`")).toBe(
"Already **done** and `closed`",
);
});

it("does not treat intraword underscores as emphasis", () => {
expect(finalizeStreamingMarkdown("snake_case and __bold")).toBe("snake_case and __bold__");
});

it("closes nested emphasis in reverse order", () => {
expect(finalizeStreamingMarkdown("**bold *italic")).toBe("**bold *italic***");
});

it("closes unfinished inline markdown links", () => {
expect(finalizeStreamingMarkdown("See [docs](https://example.com/path")).toBe(
"See [docs](https://example.com/path)",
);
});

it("closes nested parentheses inside unfinished link destinations", () => {
expect(finalizeStreamingMarkdown("See [docs](https://example.com/path(foo")).toBe(
"See [docs](https://example.com/path(foo))",
);
});

it("ignores link-like syntax inside inline code", () => {
expect(finalizeStreamingMarkdown("Use `[docs](https://example.com`")).toBe(
"Use `[docs](https://example.com`",
);
});

it("closes unfinished strikethrough markers", () => {
expect(finalizeStreamingMarkdown("This is ~~important")).toBe("This is ~~important~~");
});

it("closes unfinished autolinks", () => {
expect(finalizeStreamingMarkdown("Visit <https://example.com/path")).toBe(
"Visit <https://example.com/path>",
);
});

it("does not keep autolinks open across whitespace", () => {
expect(finalizeStreamingMarkdown("Visit <https://example.com path")).toBe(
"Visit <https://example.com path",
);
});
});
243 changes: 243 additions & 0 deletions apps/web/src/streaming-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Streaming markdown is often syntactically incomplete mid-token. This helper
// temporarily appends closing delimiters for a small set of inline constructs so
// `react-markdown` can render partial assistant output without showing raw markup.
const FENCE_OPEN_PATTERN = /^[ \t]{0,3}((`{3,}|~{3,}))(.*)$/;
const FENCE_CLOSE_PATTERN = /^[ \t]{0,3}((`{3,}|~{3,}))[ \t]*$/;

function isWhitespace(char: string | undefined): boolean {
return char == null || /\s/.test(char);
}

function isWordChar(char: string | undefined): boolean {
return char != null && /[A-Za-z0-9]/.test(char);
}

interface InlineMarkdownScanState {
inlineCodeDelimiter: number | null;
openLinkLabelDepth: number;
openLinkDestinationDepths: number[];
openAutolink: boolean;
}

function scanInlineMarkdown(line: string, emphasisStack: string[], state: InlineMarkdownScanState) {
let activeInlineCodeDelimiter = state.inlineCodeDelimiter;
let openLinkLabelDepth = state.openLinkLabelDepth;
const openLinkDestinationDepths = [...state.openLinkDestinationDepths];
let openAutolink = state.openAutolink;

for (let index = 0; index < line.length; ) {
const char = line[index];
if (char == null) {
break;
}

if (char === "\\") {
index += Math.min(2, line.length - index);
continue;
}

if (char === "`") {
let endIndex = index + 1;
while (line[endIndex] === "`") {
endIndex += 1;
}

const delimiterLength = endIndex - index;
if (activeInlineCodeDelimiter === delimiterLength) {
activeInlineCodeDelimiter = null;
} else if (activeInlineCodeDelimiter == null) {
activeInlineCodeDelimiter = delimiterLength;
}

index = endIndex;
continue;
}

if (activeInlineCodeDelimiter == null) {
if (openAutolink) {
if (char === ">") {
openAutolink = false;
} else if (isWhitespace(char) || char === "<") {
openAutolink = false;
}
index += 1;
continue;
}

if (char === "<" && /^(https?:\/\/|mailto:)/i.test(line.slice(index + 1))) {
openAutolink = true;
index += 1;
continue;
}

if (char === "[") {
openLinkLabelDepth += 1;
index += 1;
continue;
}

if (char === "]") {
if (openLinkLabelDepth > 0 && line[index + 1] === "(") {
openLinkLabelDepth -= 1;
openLinkDestinationDepths.push(0);
index += 2;
continue;
}

if (openLinkLabelDepth > 0) {
openLinkLabelDepth -= 1;
}

index += 1;
continue;
}

if (char === "(" && openLinkDestinationDepths.length > 0) {
const lastIndex = openLinkDestinationDepths.length - 1;
const currentDepth = openLinkDestinationDepths[lastIndex];
if (currentDepth != null) {
openLinkDestinationDepths[lastIndex] = currentDepth + 1;
}
index += 1;
continue;
}

if (char === ")" && openLinkDestinationDepths.length > 0) {
const lastIndex = openLinkDestinationDepths.length - 1;
const currentDepth = openLinkDestinationDepths[lastIndex];
if (currentDepth === 0) {
openLinkDestinationDepths.pop();
} else if (currentDepth != null) {
openLinkDestinationDepths[lastIndex] = currentDepth - 1;
}
index += 1;
continue;
}

if (char === "~" && line[index + 1] === "~") {
let endIndex = index + 2;
while (line[endIndex] === "~") {
endIndex += 1;
}

const pairCount = Math.floor((endIndex - index) / 2);
for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) {
const topDelimiter = emphasisStack[emphasisStack.length - 1];
if (topDelimiter === "~~") {
emphasisStack.pop();
} else {
emphasisStack.push("~~");
}
}

index += pairCount * 2;
continue;
}
}

if (activeInlineCodeDelimiter == null && (char === "*" || char === "_")) {
let endIndex = index + 1;
while (line[endIndex] === char) {
endIndex += 1;
}

const delimiter = line.slice(index, endIndex);
const previousChar = index > 0 ? line[index - 1] : undefined;
const nextChar = line[endIndex];
const canOpen = !isWhitespace(nextChar);
const canClose = !isWhitespace(previousChar);
const isIntrawordUnderscore =
char === "_" && isWordChar(previousChar) && isWordChar(nextChar);

if (!isIntrawordUnderscore) {
const topDelimiter = emphasisStack[emphasisStack.length - 1];
if (canClose && topDelimiter === delimiter) {
emphasisStack.pop();
} else if (canOpen) {
emphasisStack.push(delimiter);
}
}

index = endIndex;
continue;
}

index += 1;
}

return {
inlineCodeDelimiter: activeInlineCodeDelimiter,
openLinkLabelDepth,
openLinkDestinationDepths,
openAutolink,
};
}

export function finalizeStreamingMarkdown(text: string): string {
if (text.length === 0) {
return text;
}

const lines = text.split("\n");
const emphasisStack: string[] = [];
let inlineState: InlineMarkdownScanState = {
inlineCodeDelimiter: null,
openLinkLabelDepth: 0,
openLinkDestinationDepths: [],
openAutolink: false,
};
let openFence: { marker: "`" | "~"; length: number } | null = null;

for (const line of lines) {
if (openFence != null) {
const closingMatch = line.match(FENCE_CLOSE_PATTERN);
if (closingMatch) {
const closingDelimiter = closingMatch[1] ?? "";
if (
closingDelimiter.startsWith(openFence.marker) &&
closingDelimiter.length >= openFence.length
) {
openFence = null;
}
}
continue;
}

const openingMatch = line.match(FENCE_OPEN_PATTERN);
if (openingMatch) {
Comment on lines +206 to +207
Copy link
Contributor

Choose a reason for hiding this comment

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

🟑 Medium src/streaming-markdown.ts:206

When an unclosed inline code span is followed by a fence-like pattern on the next line (e.g., `some code\n```python), the function incorrectly enters fence mode and resets inlineState, losing track of the unclosed inline code delimiter. The original backtick remains unclosed in the output, causing incorrect markdown rendering. Consider checking inlineState.inlineCodeDelimiter == null before processing fence open patterns.

     const openingMatch = line.match(FENCE_OPEN_PATTERN);
-    if (openingMatch) {
+    if (openingMatch && inlineState.inlineCodeDelimiter == null) {
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/streaming-markdown.ts around lines 206-207:

When an unclosed inline code span is followed by a fence-like pattern on the next line (e.g., `` `some code\n[code fence]python ``), the function incorrectly enters fence mode and resets `inlineState`, losing track of the unclosed inline code delimiter. The original backtick remains unclosed in the output, causing incorrect markdown rendering. Consider checking `inlineState.inlineCodeDelimiter == null` before processing fence open patterns.

Evidence trail:
apps/web/src/streaming-markdown.ts lines 205-217 (REVIEWED_COMMIT): Fence open pattern handling that resets `inlineState` without checking if there's an unclosed inline code delimiter. Lines 229-231: Suffix generation that relies on `inlineState.inlineCodeDelimiter` to add closing backticks - but this value is null after the reset.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@macroscopeapp fix it for me

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix it for me

const delimiter = openingMatch[1] ?? "";
const marker = delimiter[0];
if (marker === "`" || marker === "~") {
openFence = { marker, length: delimiter.length };
inlineState = {
inlineCodeDelimiter: null,
openLinkLabelDepth: 0,
openLinkDestinationDepths: [],
openAutolink: false,
};
}
continue;
}

inlineState = scanInlineMarkdown(line, emphasisStack, inlineState);
}

let suffix = "";
if (openFence != null) {
suffix += `${text.endsWith("\n") ? "" : "\n"}${openFence.marker.repeat(openFence.length)}`;
}
if (inlineState.inlineCodeDelimiter != null) {
suffix += "`".repeat(inlineState.inlineCodeDelimiter);
}
if (inlineState.openLinkDestinationDepths.length > 0) {
suffix += inlineState.openLinkDestinationDepths.map((depth) => ")".repeat(depth + 1)).join("");
}
if (inlineState.openAutolink) {
suffix += ">";
}
if (emphasisStack.length > 0) {
suffix += emphasisStack.toReversed().join("");
}

return `${text}${suffix}`;
}