Files
paseo/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts
T
Mohamed Boudra 135a3b4c9e Find text in assistant messages that streamed in the open chat (#5146)
* fix(app): find text in assistant messages that streamed in the open chat

Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.

The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.

* fix(app): let the Markdown parser decide which blocks are link definitions

A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
2026-09-21 22:31:08 +02:00

128 lines
4.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { splitMarkdownBlocks } from "../split-markdown-blocks";
describe("splitMarkdownBlocks", () => {
it("returns a single block for a single paragraph", () => {
expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]);
});
it("splits two paragraphs separated by a double newline", () => {
expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([
"First paragraph",
"Second paragraph",
]);
});
it("keeps a fenced code block with internal double newlines as one block", () => {
expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([
"```ts\nconst a = 1;\n\nconst b = 2;\n```",
]);
});
it("does not treat 4-space-indented backticks as a fence", () => {
expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([
"Before",
" ```\n code\n ```",
"After",
]);
});
it("handles tilde fences", () => {
expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([
"Before",
"~~~\ncode\n~~~",
"After",
]);
});
it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => {
expect(
splitMarkdownBlocks(
"Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph",
),
).toEqual(["Intro paragraph", "```ts\nconst a = 1;\n\nconst b = 2;\n```", "Outro paragraph"]);
});
it("keeps everything from an unclosed fence start as one block for streaming content", () => {
expect(splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;")).toEqual([
"Before fence",
"```ts\nconst a = 1;\n\nconst b = 2;",
]);
});
it("returns an empty array for empty input", () => {
expect(splitMarkdownBlocks("")).toEqual([]);
});
it("splits a heading followed by a paragraph into two blocks", () => {
expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([
"# Heading",
"Paragraph text",
]);
});
it("keeps consecutive list items together when there is no double newline", () => {
expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([
"- First item\n- Second item\n- Third item",
]);
});
it("keeps a loose nested list in its outer list block", () => {
expect(
splitMarkdownBlocks(
"Before\n\n3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n\nAfter",
),
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
});
it("keeps a link reference definition with the paragraph that uses it", () => {
expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([
"See the [docs][d].\n\n[d]: https://example.com",
]);
});
it("folds a leading definition-only block into the block below it", () => {
expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([
'[d]: https://example.com "Docs"\n\nSee the [docs][d].',
]);
});
it("folds several definition lines and several definition blocks into one block", () => {
expect(
splitMarkdownBlocks(
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'\n\nAfter",
),
).toEqual([
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'",
"After",
]);
});
it("recognizes every destination the renderer accepts, including escaped spaces", () => {
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([
"See [docs].\n\n[docs]: docs\\ folder/readme",
]);
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: <docs folder/readme> 'Title'")).toEqual([
"See [docs].\n\n[docs]: <docs folder/readme> 'Title'",
]);
});
it("leaves a definition-only message as its own block", () => {
expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]);
});
it("does not fold a paragraph that merely starts with a bracketed link", () => {
expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([
"Intro",
"[Link](https://example.com) and more prose",
]);
});
it("treats triple newlines as a split point and filters empty blocks", () => {
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
"First paragraph",
"Second paragraph",
]);
});
});