diff --git a/apps/web/src/chat/markdown.test.ts b/apps/web/src/chat/markdown.test.ts
index fc56ed534..27ed00cc8 100644
--- a/apps/web/src/chat/markdown.test.ts
+++ b/apps/web/src/chat/markdown.test.ts
@@ -72,6 +72,17 @@ test("Chat Markdown admits formatting without document features", () => {
expect(markdown("Planner wrote **strongly**.")).toContain("strongly");
});
+test("indentation keeps nested lists and code blocks intact", () => {
+ let nested =
+ /
parent\s*\s*- child<\/li>\s*
- sibling<\/li>\s*<\/ul>\s*<\/li>\s*
- next<\/li>/;
+
+ expect(markdown("- parent\n - child\n - sibling\n- next")).toMatch(nested);
+ expect(markdown("- parent\n - child\n - sibling\n- next")).toMatch(nested);
+ expect(markdown("1. parent\n - child\n - sibling\n2. next")).toMatch(nested);
+ expect(markdown("```py\nif ready:\n run()\n```")).toContain("if ready:\n run()");
+ expect(markdown("Ask @chopin about it")).toContain("Ask about it");
+});
+
test("authoritative ranges render internal chips inside ordinary Markdown", () => {
let source = "@chopin Read **#Release: v2 (API)** and [the docs](https://example.com).";
let markup = markdown(source, [reference(source, "#Release: v2 (API)")]);
diff --git a/apps/web/src/chat/references.ts b/apps/web/src/chat/references.ts
index bebd7b2d0..bffdcecb7 100644
--- a/apps/web/src/chat/references.ts
+++ b/apps/web/src/chat/references.ts
@@ -490,6 +490,15 @@ function projectedDisplayUnits(source: string, references: Chat.Reference[]): Di
&& !word(source[offset + MENTION.length])
) {
offset += MENTION.length - 1;
+ // Whitespace is Markdown structure (indentation, code, hard breaks), so
+ // only the space left doubled by the removed mention is dropped.
+ let previous = units.at(-1);
+ if (previous && !previous.protected && /[^\S\n]/.test(previous.value)) {
+ while (
+ /[^\S\n]/.test(source[offset + 1] ?? "")
+ && !protectedRange(references, offset + 1, offset + 2)
+ ) offset++;
+ }
continue;
}
units.push({
@@ -499,23 +508,7 @@ function projectedDisplayUnits(source: string, references: Chat.Reference[]): Di
});
}
- let collapsed: DisplayUnit[] = [];
- for (let index = 0; index < units.length;) {
- let current = units[index]!;
- if (current.protected || !/[^\S\n]/.test(current.value)) {
- collapsed.push(current);
- index++;
- continue;
- }
- let end = index + 1;
- while (
- end < units.length
- && !units[end]!.protected
- && /[^\S\n]/.test(units[end]!.value)
- ) end++;
- collapsed.push(end - index >= 2 ? { ...current, value: " " } : current);
- index = end;
- }
+ let collapsed = units;
while (collapsed.length > 0 && /\s/.test(collapsed[0]!.value)) collapsed.shift();
while (collapsed.length > 0 && /\s/.test(collapsed.at(-1)!.value)) collapsed.pop();
diff --git a/e2e/chat-composer.e2e.ts b/e2e/chat-composer.e2e.ts
index 65d653c0f..56f561f7f 100644
--- a/e2e/chat-composer.e2e.ts
+++ b/e2e/chat-composer.e2e.ts
@@ -228,6 +228,24 @@ test("switching to Chat removes multiple addresses while preserving a document m
await expect(reference).toHaveAttribute("data-document-id", target);
});
+test("a sent message keeps its nested list nested", async ({ join }) => {
+ let chat = chatPane(await join("ana"));
+ let input = chatInput(chat);
+ await fillChat(input, "Plan:\n\n- parent\n - child\n - grandchild\n- next");
+ await input.press("Enter");
+
+ let message = chat.locator("[data-chat-markdown]").filter({ hasText: "grandchild" });
+ let parent = message.getByRole("listitem").filter({ hasText: "parent" }).first();
+ let child = parent.getByRole("list").getByRole("listitem").filter({ hasText: "child" }).first();
+ let grandchild = child.getByRole("list").getByRole("listitem").filter({ hasText: "grandchild" });
+ await expect(grandchild).toHaveCount(1);
+ await expect(message.getByRole("listitem").filter({ hasText: "next" }).last())
+ .not.toContainText("child");
+ let left = async (item: typeof parent) => (await item.boundingBox())!.x;
+ expect(await left(child)).toBeGreaterThan(await left(parent));
+ expect(await left(grandchild)).toBeGreaterThan(await left(child));
+});
+
test("context-menu paste undo restores the replaced native selection", async ({ join, page }) => {
let input = chatInput(chatPane(await join("ana")));
await input.fill("hello world");