Skip to content
Merged
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
19 changes: 18 additions & 1 deletion webviews/src/agent-session/acpmux/MarkdownField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { commonmark } from "@milkdown/kit/preset/commonmark";
import { gfm } from "@milkdown/kit/preset/gfm";
import { history } from "@milkdown/kit/plugin/history";
import { clipboard } from "@milkdown/kit/plugin/clipboard";
import { baseKeymap, chainCommands, selectAll } from "@milkdown/kit/prose/commands";
import { baseKeymap, chainCommands, lift, selectAll } from "@milkdown/kit/prose/commands";
import { redo, redoDepth, undo, undoDepth } from "@milkdown/kit/prose/history";
import { keymap } from "@milkdown/kit/prose/keymap";
import { splitListItem } from "@milkdown/kit/prose/schema-list";
Expand Down Expand Up @@ -708,6 +708,23 @@ const MarkdownFieldImpl = React.forwardRef<MarkdownFieldHandle, MarkdownFieldPro
});
const blockKeys = $prose(() =>
keymap({
// Milkdown replaces the base Backspace's joinBackward with joinTextblockBackward,
// which cannot lift a quote at the document's start and joins one into prior text.
// Lift its first paragraph instead, preserving text and the paragraph boundary.
Backspace: (state, dispatch) => {
const { selection } = state;
if (!(selection instanceof TextSelection) || !selection.empty) return false;
const { $from } = selection;
if (
$from.depth < 2 ||
$from.parentOffset !== 0 ||
$from.parent.type !== state.schema.nodes.paragraph ||
$from.node(-1).type !== state.schema.nodes.blockquote ||
$from.index(-1) !== 0
)
return false;
return lift(state, dispatch);
},
"Shift-Enter": (state, dispatch, editorView) => {
if (!lines.current) return true;
const item = state.schema.nodes.list_item;
Expand Down
30 changes: 30 additions & 0 deletions webviews/src/agent-session/acpmux/composerQuote.gallery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// l10n-allow-file: gallery fixtures, not shipped UI.
// Leo 2026-10-26: "and i cant delete quote block text thingy with backspace? only shift enter? strange..."
import { agentPaneEntry } from "../../gallery/format";
import { chat } from "../../gallery/fixtures/acpmux";

export default agentPaneEntry({
id: "agent-pane.composer-quote",
title: "Backspace out of a composer quote",
area: "Agent pane",
height: 420,
widths: { narrow: 400, normal: 760, wide: 1040 },
covers: ["agent-session/acpmux/MarkdownField.tsx"],
variants: {
text: {
note: "Place the caret at the quote's start and press Backspace: its text becomes a plain paragraph.",
ready: { draft: "> Keep this quote text" },
snapshot: chat([]),
},
empty: {
note: "Backspace removes an empty quote, leaving the composer ready for a plain paragraph.",
ready: { draft: ">" },
snapshot: chat([]),
},
"after-paragraph": {
note: "Unwrapping a quote keeps the earlier paragraph separate and preserves both texts.",
ready: { draft: "Earlier paragraph\n\n> Keep this quote text" },
snapshot: chat([]),
},
},
});
135 changes: 135 additions & 0 deletions webviews/test/composer-quote-backspace.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Leo 2026-10-26: "and i cant delete quote block text thingy with backspace? only shift enter? strange..."
// Drive the shared composer editor's real keymap: Backspace lifts quotes without deleting text.
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { act, createRef } from "react";
import type { Root } from "react-dom/client";
import type { MarkdownFieldHandle } from "../src/agent-session/acpmux/MarkdownField";
import { promptField, proseMirrorGlobals } from "../src/agent-session/acpmux/promptFieldTesting";

const scope = globalThis as Record<string, unknown>;
const keys = [
"window",
"document",
"navigator",
"HTMLElement",
"IS_REACT_ACT_ENVIRONMENT",
"Node",
"Element",
"MutationObserver",
"ResizeObserver",
"getSelection",
"getComputedStyle",
"requestAnimationFrame",
"cancelAnimationFrame",
"KeyboardEvent",
"InputEvent",
"FocusEvent",
] as const;
const saved = keys.map((key) => [key, key in scope, scope[key]] as const);
let dom: JSDOM;
let root: Root | undefined;
let host: HTMLElement;

beforeAll(() => {
dom = new JSDOM("<!doctype html><div id=root></div>", { pretendToBeVisual: true, url: "https://cmux.test/" });
const { window } = dom;
Object.assign(scope, {
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
Element: window.Element,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: (run: (time: number) => void) => setTimeout(() => run(0), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
KeyboardEvent: window.KeyboardEvent,
InputEvent: window.InputEvent,
FocusEvent: window.FocusEvent,
...proseMirrorGlobals(window as unknown as Window & typeof globalThis),
});
window.scrollBy = () => {};
const box = () => new window.DOMRect(0, 0, 100, 20);
Object.assign(window.Range.prototype, {
getClientRects: () => Object.assign([box()], { item: () => box() }),
getBoundingClientRect: box,
});
host = window.document.getElementById("root")!;
});

afterEach(() => {
act(() => root?.unmount());
root = undefined;
host.replaceChildren();
});

afterAll(() => {
dom.window.close();
for (const [key, had, value] of saved) {
if (had) scope[key] = value;
else delete scope[key];
}
});

const handle = createRef<MarkdownFieldHandle>();

async function render(markdown: string) {
const { createRoot } = await import("react-dom/client");
const { MarkdownField } = await import("../src/agent-session/acpmux/MarkdownField");
root = createRoot(host);
await act(async () => root!.render(<MarkdownField ref={handle} value={markdown} onChange={() => {}} />));
for (let turn = 0; turn < 200; turn++) {
if (host.querySelector("blockquote p")) return;
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
}
throw new Error("the quote editor never came up");
}

async function backspaceAtQuoteStart() {
const field = promptField(document);
await act(async () => {
field.handle.focus();
const text = field.handle.text().value;
const offset = text.indexOf(field.element.querySelector("blockquote p")!.textContent ?? "");
field.handle.writeText(text, offset, offset);
});
await act(async () => {
field.element.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "Backspace", code: "Backspace", bubbles: true, cancelable: true }),
);
});
return field;
}

test("Backspace at the start of a quote turns it into a paragraph, retaining its text", async () => {
await render("> Keep this quote text");
const field = await backspaceAtQuoteStart();
expect(field.element.querySelector("blockquote") === null).toBe(true);
expect(field.element.firstElementChild?.tagName).toBe("P");
expect(field.value).toBe("Keep this quote text");
});

test("Backspace in an empty quote removes the quote and leaves a usable paragraph", async () => {
await render(">");
const field = await backspaceAtQuoteStart();
expect(field.element.querySelector("blockquote") === null).toBe(true);
expect(field.element.firstElementChild?.tagName).toBe("P");
expect(field.value).toBe("");
});

test("unwrapping a quote after another paragraph does not join or delete either paragraph", async () => {
await render("Earlier paragraph\n\n> Keep this quote text");
const field = await backspaceAtQuoteStart();
expect(field.element.querySelector("blockquote") === null).toBe(true);
expect([...field.element.children].map((node) => node.textContent)).toEqual([
"Earlier paragraph",
"Keep this quote text",
]);
expect(field.value).toBe("Earlier paragraph\n\nKeep this quote text");
});
Loading