@@ -73,12 +79,12 @@ export function ComposerSmartLinkMenu({
role="menuitem"
className="flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-[11px] font-medium text-fg/78 transition-colors hover:bg-violet-500/[0.10] hover:text-violet-100"
onClick={() => {
- void window.ade.app.writeClipboardText(url);
+ void window.ade.app.writeClipboardText(copyText);
closeWithAnchorFocus();
}}
>
- Copy link
+ {copyLabel}
,
document.body,
diff --git a/apps/desktop/src/renderer/components/chat/assistantOutputSelection.test.ts b/apps/desktop/src/renderer/components/chat/assistantOutputSelection.test.ts
new file mode 100644
index 000000000..7110102a7
--- /dev/null
+++ b/apps/desktop/src/renderer/components/chat/assistantOutputSelection.test.ts
@@ -0,0 +1,57 @@
+/* @vitest-environment jsdom */
+
+import { describe, expect, it } from "vitest";
+import { readAssistantOutputSelection } from "./assistantOutputSelection";
+
+function selectNodeContents(node: Node): Selection {
+ const selection = window.getSelection();
+ if (!selection) throw new Error("jsdom selection unavailable");
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ return selection;
+}
+
+describe("readAssistantOutputSelection", () => {
+ it("returns trimmed text only when the range is inside assistant output", () => {
+ const root = document.createElement("div");
+ const assistant = document.createElement("div");
+ assistant.dataset.assistantOutput = "true";
+ assistant.textContent = " highlighted passage ";
+ const user = document.createElement("div");
+ user.textContent = "user prompt";
+ root.append(assistant, user);
+ document.body.append(root);
+
+ const inside = readAssistantOutputSelection(root, selectNodeContents(assistant));
+ expect(inside?.text).toBe("highlighted passage");
+
+ const outside = readAssistantOutputSelection(root, selectNodeContents(user));
+ expect(outside).toBeNull();
+ root.remove();
+ });
+
+ it("returns null when a range spans assistant-output elements", () => {
+ const root = document.createElement("div");
+ const first = document.createElement("div");
+ first.dataset.assistantOutput = "true";
+ first.textContent = "first answer";
+ const second = document.createElement("div");
+ second.dataset.assistantOutput = "true";
+ second.textContent = "second answer";
+ root.append(first, second);
+ document.body.append(root);
+
+ const selection = window.getSelection();
+ if (!selection) throw new Error("jsdom selection unavailable");
+ const range = document.createRange();
+ range.setStart(first.firstChild!, 0);
+ range.setEnd(second.firstChild!, 6);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ expect(readAssistantOutputSelection(root, selection)).toBeNull();
+ root.remove();
+ });
+});
diff --git a/apps/desktop/src/renderer/components/chat/assistantOutputSelection.ts b/apps/desktop/src/renderer/components/chat/assistantOutputSelection.ts
new file mode 100644
index 000000000..198768279
--- /dev/null
+++ b/apps/desktop/src/renderer/components/chat/assistantOutputSelection.ts
@@ -0,0 +1,40 @@
+export const ASSISTANT_OUTPUT_SELECTOR = "[data-assistant-output]";
+
+export type AssistantOutputSelection = {
+ text: string;
+ rect: DOMRect;
+};
+
+function nodeElement(node: Node | null): Element | null {
+ if (!node) return null;
+ return node instanceof Element ? node : node.parentElement;
+}
+
+export function selectionIsInsideAssistantOutput(
+ selection: Selection,
+ root: HTMLElement,
+): boolean {
+ if (!selection.rangeCount) return false;
+ const range = selection.getRangeAt(0);
+ const start = nodeElement(range.startContainer);
+ const end = nodeElement(range.endContainer);
+ if (!start || !end || !root.contains(start) || !root.contains(end)) return false;
+ const startOutput = start.closest(ASSISTANT_OUTPUT_SELECTOR);
+ const endOutput = end.closest(ASSISTANT_OUTPUT_SELECTOR);
+ return Boolean(startOutput && startOutput === endOutput);
+}
+
+export function readAssistantOutputSelection(
+ root: HTMLElement | null,
+ selection: Selection | null = typeof window === "undefined" ? null : window.getSelection(),
+): AssistantOutputSelection | null {
+ if (!root || !selection || selection.isCollapsed || !selection.rangeCount) return null;
+ if (!selectionIsInsideAssistantOutput(selection, root)) return null;
+ const text = selection.toString().replace(/\r\n/g, "\n").trim();
+ if (!text) return null;
+ const range = selection.getRangeAt(0);
+ const rect = typeof range.getBoundingClientRect === "function"
+ ? range.getBoundingClientRect()
+ : new DOMRect(8, 8, 0, 0);
+ return { text, rect };
+}
diff --git a/apps/desktop/src/renderer/components/chat/composerChatOutputContext.test.ts b/apps/desktop/src/renderer/components/chat/composerChatOutputContext.test.ts
new file mode 100644
index 000000000..9ca6c797d
--- /dev/null
+++ b/apps/desktop/src/renderer/components/chat/composerChatOutputContext.test.ts
@@ -0,0 +1,20 @@
+/* @vitest-environment jsdom */
+
+import { describe, expect, it } from "vitest";
+import { formatChatOutputContextBlock } from "../../../shared/chatOutputContext";
+import { hydrateChatOutputContextChipsInEditor } from "./composerChatOutputContext";
+
+describe("hydrateChatOutputContextChipsInEditor", () => {
+ it("replaces a context block with a Chat context chip", () => {
+ const editor = document.createElement("div");
+ const block = formatChatOutputContextBlock("retry the lane")!;
+ editor.textContent = `please ${block} thanks`;
+ expect(hydrateChatOutputContextChipsInEditor(editor)).toBe(true);
+ const chip = editor.querySelector