Skip to content
Closed
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
31 changes: 31 additions & 0 deletions webviews/src/agent-session/acpmux/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from "./composerDraft";
import type { MarkdownFieldHandle, MarkdownFieldProps } from "./MarkdownField";
import { type StringKey, useT } from "./i18n";
import { appendQuoteMarkdown, QUOTE_EVENT } from "./composerQuote";
import { remoteComposer } from "./remoteEditing";
import { useComposerChrome } from "./composerChrome";
import { useQueueing } from "./queueing";
Expand Down Expand Up @@ -482,6 +483,36 @@ export function Composer({
const notTyped = () => {
typedAt.current = Number.NEGATIVE_INFINITY;
};
// The native message menu dispatches the shared Home quote event on this composer. Keep the
// existing draft first, add a blank line, and leave the caret after the trailing blank line.
useEffect(() => {
const node = form.current;
if (!node) return;
const seen = new WeakSet<Event>();
const takeQuote = (event: Event) => {
if (seen.has(event)) return;
seen.add(event);
const selected = (event as CustomEvent<unknown>).detail;
if (typeof selected !== "string" || !selected.trim()) return;
const current = field.current?.value() ?? textRef.current;
const next = appendQuoteMarkdown(current, selected);
notTyped();
if (!field.current?.type(next)) {
pendingCaret.current = next.length;
setText(next);
setCaret(next.length);
} else {
setCaret(next.length);
}
field.current?.focus();
};
const targets: EventTarget[] = [node, node.ownerDocument];
if (node.ownerDocument.defaultView) targets.push(node.ownerDocument.defaultView);
for (const target of targets) target.addEventListener(QUOTE_EVENT, takeQuote);
return () => {
for (const target of targets) target.removeEventListener(QUOTE_EVENT, takeQuote);
};
}, []);
/// The person edited this chat's prompt before its durable draft read answered (a clear too):
/// that read keeps their text, empty or not, and a quit before it answers saves it.
const editedBeforeHydration = useRef(false);
Expand Down
18 changes: 16 additions & 2 deletions webviews/src/agent-session/acpmux/composerQuote.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { chat } from "../../gallery/fixtures/acpmux";

export default agentPaneEntry({
id: "agent-pane.composer-quote",
title: "Backspace out of a composer quote",
title: "Composer quotes stay at the bottom of the reply",
area: "Agent pane",
height: 420,
widths: { narrow: 400, normal: 760, wide: 1040 },
covers: ["agent-session/acpmux/MarkdownField.tsx"],
covers: [
"agent-session/acpmux/Composer.tsx",
"agent-session/acpmux/MarkdownField.tsx",
"agent-session/acpmux/composerQuote.ts",
],
variants: {
text: {
note: "Place the caret at the quote's start and press Backspace: its text becomes a plain paragraph.",
Expand All @@ -26,5 +30,15 @@ export default agentPaneEntry({
ready: { draft: "Earlier paragraph\n\n> Keep this quote text" },
snapshot: chat([]),
},
bottom: {
note: "Quote in Reply appends the selected text after the existing draft, with a quiet bar and blank line.",
ready: { draft: "Reply text\n\n> Keep this quote text\n\n" },
snapshot: chat([]),
},
stacked: {
note: "Successive Quote in Reply actions keep their order at the bottom of the draft.",
ready: { draft: "Reply text\n\n> First quote\n\n> Second quote\n\n" },
snapshot: chat([]),
},
},
});
13 changes: 13 additions & 0 deletions webviews/src/agent-session/acpmux/composerQuote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Quote in Reply shares the native selection event with Home Channels. The agent pane keeps
// the insertion at the bottom of its current draft so the reply stays after what is already typed.
import { QUOTE_EVENT, quoteMarkdown } from "../../pages/home-channels/quoteSelection";

export { QUOTE_EVENT, quoteMarkdown };

/** Append one Markdown quote to a draft, leaving one blank line on either side. */
export function appendQuoteMarkdown(current: string, selected: string): string {
const quote = quoteMarkdown(selected);
if (!current) return quote;
const separator = current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
return `${current}${separator}${quote}`;
}
21 changes: 21 additions & 0 deletions webviews/test/composer-quote-backspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { appendQuoteMarkdown } from "../src/agent-session/acpmux/composerQuote";
import { promptField, proseMirrorGlobals } from "../src/agent-session/acpmux/promptFieldTesting";

const scope = globalThis as Record<string, unknown>;
Expand Down Expand Up @@ -133,3 +134,23 @@ test("unwrapping a quote after another paragraph does not join or delete either
]);
expect(field.value).toBe("Earlier paragraph\n\nKeep this quote text");
});

test("Quote in Reply appends after an existing draft and keeps successive quotes in order", () => {
const first = appendQuoteMarkdown("Keep this draft", "first selected line\nsecond selected line");
expect(first).toBe("Keep this draft\n\n> first selected line\n> second selected line\n\n");
expect(appendQuoteMarkdown(first, "second quote")).toBe(
"Keep this draft\n\n> first selected line\n> second selected line\n\n> second quote\n\n",
);
});

test("a composer quote is rendered as a blockquote while its value stays Markdown", async () => {
const markdown = appendQuoteMarkdown("Reply text", "Quoted text");
await render(markdown);
const field = promptField(document);
const blockquote = field.element.querySelector("blockquote");
expect(blockquote).not.toBeNull();
expect(blockquote?.textContent).toBe("Quoted text");
expect(field.element.textContent).toContain("Reply text");
expect(field.value).toBe("Reply text\n\n> Quoted text");
expect(field.value).not.toContain("Reply text\n\n> Quoted text\n\n> Quoted text");
});
Loading