diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index b96ecc42a..7945fabec 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -11,6 +11,7 @@ import type { import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { AgentChatComposer } from "./AgentChatComposer"; import { useAppStore } from "../../state/appStore"; +import { formatChatOutputContextBlock } from "../../../shared/chatOutputContext"; function installMatchMediaMock(): void { if (typeof window.matchMedia === "function") return; @@ -335,6 +336,26 @@ describe("AgentChatComposer", () => { expect(screen.getByRole("textbox").className).toContain("text-left"); }); + it("hydrates highlighted assistant output as an inline Chat context chip", async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined); + (window as any).ade = { app: { writeClipboardText } }; + const block = formatChatOutputContextBlock("retry the lane checkout")!; + const view = render(); + + const chip = await waitFor(() => { + const el = view.container.querySelector("[data-composer-chip='chat-context']"); + if (!el) throw new Error("chat context chip not rendered"); + return el; + }); + expect(chip.textContent).toContain("Chat context"); + fireEvent.click(chip); + fireEvent.click(screen.getByRole("menuitem", { name: /Copy/ })); + expect(writeClipboardText).toHaveBeenCalledWith("retry the lane checkout"); + }); + it("clear draft only triggers the draft-clear action during an active turn", () => { const props = renderComposer(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 9a1ace109..e25cb42a9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -97,6 +97,8 @@ import { shouldReconcileSmartLinkDraft, type SmartLinkPreview, } from "../../../shared/smartLinks"; +import { hasChatOutputContext } from "../../../shared/chatOutputContext"; +import { hydrateChatOutputContextChipsInEditor } from "./composerChatOutputContext"; import { SmartTooltip } from "../ui/SmartTooltip"; import { VoiceDictationButton } from "./VoiceDictationButton"; import { ProviderLogo } from "../shared/ProviderLogos"; @@ -1828,7 +1830,9 @@ export function AgentChatComposer({ const [selectedIosContextId, setSelectedIosContextId] = useState(null); const [selectedAppControlContextId, setSelectedAppControlContextId] = useState(null); const [selectedBuiltInBrowserContextId, setSelectedBuiltInBrowserContextId] = useState(null); - const [smartLinkEditorEnabled, setSmartLinkEditorEnabled] = useState(() => findSmartLinks(draft).length > 0); + const [smartLinkEditorEnabled, setSmartLinkEditorEnabled] = useState( + () => findSmartLinks(draft).length > 0 || hasChatOutputContext(draft), + ); const [selectedSmartLinkNode, setSelectedSmartLinkNode] = useState(null); const [activeTurnSendMode, setActiveTurnSendMode] = useState("inline"); const [activeTurnStopMode, setActiveTurnStopMode] = useState("stop_and_clear"); @@ -1837,8 +1841,8 @@ export function AgentChatComposer({ : activeTurnSendMode; useEffect(() => { - setActiveTurnSendMode("inline"); - }, [sessionId, turnActive]); + if (hasChatOutputContext(draft)) setSmartLinkEditorEnabled(true); + }, [draft]); useEffect(() => { if (!sessionId) { @@ -2498,6 +2502,7 @@ export function AgentChatComposer({ const editor = richEditorRef.current; if (!editor) return draft; const parts: string[] = []; + const preservedChipText = new Map(); const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { parts.push(node.textContent ?? ""); @@ -2505,7 +2510,13 @@ export function AgentChatComposer({ } if (!(node instanceof HTMLElement)) return; if (node.dataset.composerChipText != null) { - parts.push(node.dataset.composerChipText); + if (node.dataset.composerChip === "chat-context") { + const token = `\u0000ctx${preservedChipText.size}\u0000`; + preservedChipText.set(token, node.dataset.composerChipText); + parts.push(token); + } else { + parts.push(node.dataset.composerChipText); + } return; } if ( @@ -2524,11 +2535,15 @@ export function AgentChatComposer({ if (node.tagName === "DIV" || node.tagName === "P") parts.push("\n"); }; editor.childNodes.forEach(visit); - return parts + let serialized = parts .join("") .replace(/\u00a0/g, " ") .replace(/[ \t]{2,}/g, " ") .replace(/[ \t]+\n/g, "\n"); + for (const [token, value] of preservedChipText) { + serialized = serialized.replace(token, value); + } + return serialized; }, [draft]); const syncRichDraft = useCallback(() => { @@ -2764,7 +2779,8 @@ export function AgentChatComposer({ } else { candidate = range.startContainer.childNodes[direction === "backward" ? range.startOffset - 1 : range.startOffset] ?? null; } - if (!(candidate instanceof HTMLElement) || !candidate.dataset.smartLinkUrl) return false; + if (!(candidate instanceof HTMLElement)) return false; + if (!candidate.dataset.smartLinkUrl && candidate.dataset.composerChip !== "chat-context") return false; removeSmartLinkNode(candidate); return true; }, [removeSmartLinkNode]); @@ -3248,6 +3264,7 @@ export function AgentChatComposer({ } hydrateMentionChipsInEditor(); + hydrateChatOutputContextChipsInEditor(editor); const isFocusedInsideEditor = document.activeElement === editor; const insertChipFragment = (chip: HTMLElement) => { @@ -4025,18 +4042,18 @@ export function AgentChatComposer({ } if (event.currentTarget instanceof HTMLDivElement) { - const focusedSmartLink = document.activeElement instanceof HTMLElement - ? document.activeElement.closest("[data-smart-link-url]") + const focusedChip = document.activeElement instanceof HTMLElement + ? document.activeElement.closest("[data-smart-link-url], [data-composer-chip='chat-context']") : null; - if (focusedSmartLink && event.currentTarget.contains(focusedSmartLink)) { + if (focusedChip && event.currentTarget.contains(focusedChip)) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - setSelectedSmartLinkNode(focusedSmartLink); + setSelectedSmartLinkNode(focusedChip); return; } if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); - removeSmartLinkNode(focusedSmartLink); + removeSmartLinkNode(focusedChip); return; } } @@ -5713,6 +5730,13 @@ export function AgentChatComposer({ setSelectedSmartLinkNode(smartLinkChip); return; } + const chatContextChip = target?.closest?.("[data-composer-chip='chat-context']") as HTMLElement | null; + if (chatContextChip) { + event.preventDefault(); + event.stopPropagation(); + setSelectedSmartLinkNode(chatContextChip); + return; + } const iosChip = target?.closest?.("[data-ios-context-id]") as HTMLElement | null; if (iosChip?.dataset.iosContextId) { event.preventDefault(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 53e8e85f8..bc8b843d6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -935,6 +935,48 @@ describe("AgentChatMessageList transcript rendering", () => { await waitFor(() => expect(writeText).toHaveBeenCalledWith("First block.\n\nSecond block.")); }); + it("adds selected assistant text to the composer as chat context", async () => { + const onInsertDraft = vi.fn(); + renderMessageList( + [{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "Retry the lane checkout.", itemId: "text-1", turnId: "turn-1" }, + }], + { onInsertDraft }, + ); + + const output = document.querySelector("[data-assistant-output]"); + expect(output).toBeTruthy(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(output!); + selection?.removeAllRanges(); + selection?.addRange(range); + fireEvent.mouseUp(document); + + const add = await screen.findByTestId("assistant-output-add-to-chat"); + fireEvent.click(add); + expect(onInsertDraft).toHaveBeenCalledTimes(1); + expect(String(onInsertDraft.mock.calls[0]?.[0])).toContain("Retry the lane checkout."); + expect(String(onInsertDraft.mock.calls[0]?.[0])).toContain("added it as context"); + }); + + it("renders sent chat-context tags as Chat context chips", () => { + renderMessageList([{ + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "user_message", + text: `please \nThe user highlighted the following text from your previous output and added it as context:\n\nRetry the lane checkout.\n thanks`, + deliveryState: "delivered", + }, + }]); + expect(screen.getByTestId("user-message-chat-context-chip").textContent).toBe("Chat context"); + expect(screen.getByText(/please/)).toBeTruthy(); + expect(screen.getByText(/thanks/)).toBeTruthy(); + }); + it("does not add turn-copy chrome for single-block or legacy null-turn text", () => { const { rerender } = renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 0eb791ee4..89fdc3bd9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -60,6 +60,11 @@ import { formatTime } from "../../lib/format"; import { navigateToAppTarget, openExternalUrl, openUrlInAdeBrowser } from "../../lib/openExternal"; import { normalizePath } from "../../lib/pathUtils"; import { chatMarkdownUrlTransform } from "./chatMarkdown"; +import { + CHAT_OUTPUT_CONTEXT_CHIP_LABEL, + splitChatOutputContextSegments, +} from "../../../shared/chatOutputContext"; +import { AssistantOutputSelectionToolbar } from "./AssistantOutputSelectionToolbar"; import { ChatWorkspacePathProvider, looksLikeWorkspacePath, @@ -799,6 +804,18 @@ function parseLeadingIosContextChips(text: string): { chips: string[]; rest: str return { chips, rest: text.slice(i) }; } +function ChatOutputContextChip({ quote }: { quote: string }) { + return ( + + {CHAT_OUTPUT_CONTEXT_CHIP_LABEL} + + ); +} + function UserMessageSendConfirmations({ event, }: { @@ -2739,25 +2756,35 @@ function renderEvent( ); } const parsed = parseLeadingIosContextChips(event.text); - const body = !parsed.chips.length ? ( + const contextSegments = splitChatOutputContextSegments(parsed.rest); + const hasOutputContext = contextSegments.some((segment) => segment.kind === "context"); + const body = !parsed.chips.length && !hasOutputContext ? (
{event.text}
) : (
- - {parsed.chips.map((label, idx) => ( - - {label} - - ))} - - {parsed.rest} + {parsed.chips.length ? ( + + {parsed.chips.map((label, idx) => ( + + {label} + + ))} + + ) : null} + {hasOutputContext + ? contextSegments.map((segment, idx) => ( + segment.kind === "text" + ? {segment.text} + : + )) + : parsed.rest}
); // Only the plain prompt body clamps. The hidden-prompt brief and the @@ -2807,7 +2834,7 @@ function renderEvent( /> ) : null} -
+
@@ -6826,6 +6853,7 @@ function AgentChatMessageListMain({ {newRowsSinceDetach > 0 ? `${newRowsSinceDetach} new ยท jump to latest` : "Jump to latest"} ) : null} + ); diff --git a/apps/desktop/src/renderer/components/chat/AssistantOutputSelectionToolbar.tsx b/apps/desktop/src/renderer/components/chat/AssistantOutputSelectionToolbar.tsx new file mode 100644 index 000000000..7fc76a2eb --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AssistantOutputSelectionToolbar.tsx @@ -0,0 +1,90 @@ +import React, { useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChatCircleText } from "@phosphor-icons/react"; +import { formatChatOutputContextBlock } from "../../../shared/chatOutputContext"; +import { readAssistantOutputSelection } from "./assistantOutputSelection"; + +type ToolbarState = { + text: string; + left: number; + top: number; +}; + +export function AssistantOutputSelectionToolbar({ + rootRef, + onAddToChat, +}: { + rootRef: { current: HTMLElement | null }; + onAddToChat?: (text: string) => void; +}) { + const [state, setState] = useState(null); + + const sync = () => { + if (!onAddToChat) { + setState(null); + return; + } + try { + const next = readAssistantOutputSelection(rootRef.current); + if (!next) { + setState(null); + return; + } + const width = 118; + setState({ + text: next.text, + left: Math.min(Math.max(8, next.rect.right + 8), Math.max(8, window.innerWidth - width - 8)), + top: Math.max(8, next.rect.top - 36), + }); + } catch { + setState(null); + } + }; + + useLayoutEffect(() => { + if (!onAddToChat) return; + const handleSelection = () => sync(); + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setState(null); + }; + document.addEventListener("selectionchange", handleSelection); + document.addEventListener("mouseup", handleSelection); + window.addEventListener("resize", handleSelection); + window.addEventListener("scroll", handleSelection, true); + window.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("selectionchange", handleSelection); + document.removeEventListener("mouseup", handleSelection); + window.removeEventListener("resize", handleSelection); + window.removeEventListener("scroll", handleSelection, true); + window.removeEventListener("keydown", handleKey); + }; + }, [onAddToChat, rootRef]); + + if (!state || !onAddToChat) return null; + + return createPortal( + , + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/chat/ComposerSmartLinkMenu.tsx b/apps/desktop/src/renderer/components/chat/ComposerSmartLinkMenu.tsx index 0c902186c..dd0d01945 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerSmartLinkMenu.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerSmartLinkMenu.tsx @@ -13,7 +13,13 @@ export function ComposerSmartLinkMenu({ }) { const menuRef = useRef(null); const [position, setPosition] = useState({ left: 8, top: 8 }); - const url = anchor.dataset.smartLinkUrl ?? ""; + const isChatContext = anchor.dataset.composerChip === "chat-context"; + const copyText = isChatContext + ? (anchor.dataset.chatOutputQuote ?? "") + : (anchor.dataset.smartLinkUrl ?? ""); + const copyLabel = isChatContext ? "Copy" : "Copy link"; + const removeLabel = isChatContext ? "Remove" : "Remove link"; + const menuLabel = isChatContext ? "Chat context actions" : "Link actions"; const closeWithAnchorFocus = useCallback(() => { if (anchor.isConnected) anchor.focus({ preventScroll: true }); onClose(); @@ -64,7 +70,7 @@ export function ComposerSmartLinkMenu({
@@ -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("[data-composer-chip='chat-context']"); + expect(chip?.textContent).toBe("Chat context"); + expect(chip?.dataset.chatOutputQuote).toBe("retry the lane"); + expect(editor.textContent).toContain("please"); + expect(editor.textContent).toContain("thanks"); + expect(editor.textContent).not.toContain(""); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/composerChatOutputContext.ts b/apps/desktop/src/renderer/components/chat/composerChatOutputContext.ts new file mode 100644 index 000000000..94abdefb6 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/composerChatOutputContext.ts @@ -0,0 +1,67 @@ +import { + CHAT_OUTPUT_CONTEXT_CHIP_LABEL, + extractChatOutputContextQuote, + hasChatOutputContext, + parseChatOutputContextBlocks, +} from "../../../shared/chatOutputContext"; + +const CHIP_CLASS = + "mx-0.5 inline-flex max-w-[280px] translate-y-[1px] cursor-default items-center gap-1.5 rounded-md border border-violet-300/24 bg-violet-500/13 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] font-medium leading-5 text-violet-100/90 align-baseline outline-none transition-colors hover:border-violet-300/38 hover:bg-violet-500/18 focus:border-violet-200/45 focus:ring-1 focus:ring-violet-300/30"; + +export function createChatOutputContextChipNode(block: string): HTMLElement { + const chip = document.createElement("span"); + chip.contentEditable = "false"; + chip.tabIndex = 0; + chip.role = "button"; + chip.dataset.composerChip = "chat-context"; + chip.dataset.composerChipText = block; + chip.dataset.chatOutputQuote = extractChatOutputContextQuote(block); + chip.className = CHIP_CLASS; + chip.title = chip.dataset.chatOutputQuote || CHAT_OUTPUT_CONTEXT_CHIP_LABEL; + chip.setAttribute( + "aria-label", + `${CHAT_OUTPUT_CONTEXT_CHIP_LABEL}. ${chip.dataset.chatOutputQuote || ""}`.trim(), + ); + const label = document.createElement("span"); + label.className = "truncate"; + label.textContent = CHAT_OUTPUT_CONTEXT_CHIP_LABEL; + chip.appendChild(label); + return chip; +} + +export function hydrateChatOutputContextChipsInEditor(editor: HTMLElement): boolean { + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if ( + !parent + || parent.closest("[data-composer-chip], [data-ios-context-id], [data-app-control-context-id], [data-built-in-browser-context-id]") + ) { + return NodeFilter.FILTER_REJECT; + } + return hasChatOutputContext(node.textContent ?? "") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }); + const nodes: Text[] = []; + let current = walker.nextNode(); + while (current) { + nodes.push(current as Text); + current = walker.nextNode(); + } + if (!nodes.length) return false; + for (const node of nodes) { + const text = node.textContent ?? ""; + const matches = parseChatOutputContextBlocks(text); + if (!matches.length) continue; + const fragment = document.createDocumentFragment(); + let offset = 0; + for (const match of matches) { + if (match.start > offset) fragment.append(document.createTextNode(text.slice(offset, match.start))); + fragment.append(createChatOutputContextChipNode(match.block)); + offset = match.end; + } + if (offset < text.length) fragment.append(document.createTextNode(text.slice(offset))); + node.replaceWith(fragment); + } + return true; +} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 6e2786f76..ebb66af7f 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2729,6 +2729,21 @@ button:active, [role="button"]:active { opacity: 0.35; } +@keyframes ade-assistant-add-to-chat-in { + from { opacity: 0; transform: translateY(4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.ade-assistant-add-to-chat { + animation: ade-assistant-add-to-chat-in 120ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .ade-assistant-add-to-chat { + animation: none; + } +} + .ade-liquid-glass-menu { border-radius: 16px; border: 1px solid var(--work-popover-border, var(--chat-panel-border)); diff --git a/apps/desktop/src/shared/chatOutputContext.test.ts b/apps/desktop/src/shared/chatOutputContext.test.ts new file mode 100644 index 000000000..2b104357a --- /dev/null +++ b/apps/desktop/src/shared/chatOutputContext.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + CHAT_OUTPUT_CONTEXT_CHIP_LABEL, + CHAT_OUTPUT_CONTEXT_PREAMBLE, + MAX_CHAT_OUTPUT_CONTEXT_CHARS, + extractChatOutputContextQuote, + formatChatOutputContextBlock, + hasChatOutputContext, + parseChatOutputContextBlocks, + splitChatOutputContextSegments, +} from "./chatOutputContext"; + +describe("chatOutputContext", () => { + it("formats a highlighted passage as agent-facing context", () => { + const block = formatChatOutputContextBlock(" fix the retry loop "); + expect(block).toContain(CHAT_OUTPUT_CONTEXT_PREAMBLE); + expect(extractChatOutputContextQuote(block!)).toBe("fix the retry loop"); + expect(hasChatOutputContext(block!)).toBe(true); + }); + + it("returns null for whitespace-only selections", () => { + expect(formatChatOutputContextBlock(" \n\t ")).toBeNull(); + }); + + it("neutralizes forged context tags inside the quote", () => { + const block = formatChatOutputContextBlock("before after"); + expect(block).not.toContain(" after"); + expect(extractChatOutputContextQuote(block!)).toContain("ade-chat-context"); + expect(parseChatOutputContextBlocks(block!)).toHaveLength(1); + }); + + it("caps oversized selections", () => { + const block = formatChatOutputContextBlock("x".repeat(MAX_CHAT_OUTPUT_CONTEXT_CHARS + 40)); + expect(extractChatOutputContextQuote(block!).length).toBe(MAX_CHAT_OUTPUT_CONTEXT_CHARS); + }); + + it("does not split a surrogate pair at the selection limit", () => { + const emoji = "๐Ÿ˜€"; + const prefix = "x".repeat(MAX_CHAT_OUTPUT_CONTEXT_CHARS - 1); + const quote = extractChatOutputContextQuote(formatChatOutputContextBlock(prefix + emoji)!); + expect(quote.endsWith(emoji)).toBe(false); + expect(quote).toBe(prefix); + expect(quote.length).toBe(MAX_CHAT_OUTPUT_CONTEXT_CHARS - 1); + }); + + it("splits inline chips so prose can sit before and after them", () => { + const block = formatChatOutputContextBlock("selected line")!; + const text = `please ${block} thanks`; + const segments = splitChatOutputContextSegments(text); + expect(segments).toEqual([ + { kind: "text", text: "please " }, + { kind: "context", quote: "selected line", block }, + { kind: "text", text: " thanks" }, + ]); + expect(CHAT_OUTPUT_CONTEXT_CHIP_LABEL).toBe("Chat context"); + }); +}); diff --git a/apps/desktop/src/shared/chatOutputContext.ts b/apps/desktop/src/shared/chatOutputContext.ts new file mode 100644 index 000000000..233cce149 --- /dev/null +++ b/apps/desktop/src/shared/chatOutputContext.ts @@ -0,0 +1,102 @@ +export const CHAT_OUTPUT_CONTEXT_CHIP_LABEL = "Chat context"; +export const CHAT_OUTPUT_CONTEXT_OPEN = ""; +export const CHAT_OUTPUT_CONTEXT_CLOSE = ""; +export const CHAT_OUTPUT_CONTEXT_PREAMBLE = + "The user highlighted the following text from your previous output and added it as context:"; +export const MAX_CHAT_OUTPUT_CONTEXT_CHARS = 16_384; + +export type ChatOutputContextMatch = { + start: number; + end: number; + block: string; + quote: string; +}; + +export type ChatOutputContextSegment = + | { kind: "text"; text: string } + | { kind: "context"; quote: string; block: string }; + +function normalizeNewlines(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function neutralizeChatOutputContextText(text: string): string { + return text.replace(/<\/?ade-chat-context>/gi, (match) => + match.replace(/ade-chat-context/i, "ade-chat-context\u200b"), + ); +} + +export function hasChatOutputContext(text: string): boolean { + return text.includes(CHAT_OUTPUT_CONTEXT_OPEN); +} + +export function extractChatOutputContextQuote(block: string): string { + const open = block.indexOf(CHAT_OUTPUT_CONTEXT_OPEN); + const close = block.lastIndexOf(CHAT_OUTPUT_CONTEXT_CLOSE); + if (open < 0 || close < 0 || close <= open) return ""; + const inner = block + .slice(open + CHAT_OUTPUT_CONTEXT_OPEN.length, close) + .replace(/^\n/, "") + .replace(/\n$/, ""); + if (inner.startsWith(CHAT_OUTPUT_CONTEXT_PREAMBLE)) { + return inner.slice(CHAT_OUTPUT_CONTEXT_PREAMBLE.length).replace(/^\n+/, ""); + } + return inner; +} + +function clipChatOutputContextQuote(text: string): string { + if (text.length <= MAX_CHAT_OUTPUT_CONTEXT_CHARS) return text; + let end = MAX_CHAT_OUTPUT_CONTEXT_CHARS; + const last = text.charCodeAt(end - 1); + const next = text.charCodeAt(end); + if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + end -= 1; + } + return text.slice(0, end); +} + +export function formatChatOutputContextBlock(selectedText: string): string | null { + const clipped = neutralizeChatOutputContextText(normalizeNewlines(selectedText).trim()); + if (!clipped) return null; + const quote = clipChatOutputContextQuote(clipped); + return `${CHAT_OUTPUT_CONTEXT_OPEN}\n${CHAT_OUTPUT_CONTEXT_PREAMBLE}\n\n${quote}\n${CHAT_OUTPUT_CONTEXT_CLOSE}`; +} + +export function parseChatOutputContextBlocks(text: string): ChatOutputContextMatch[] { + const matches: ChatOutputContextMatch[] = []; + let from = 0; + while (from < text.length) { + const start = text.indexOf(CHAT_OUTPUT_CONTEXT_OPEN, from); + if (start < 0) break; + const closeAt = text.indexOf(CHAT_OUTPUT_CONTEXT_CLOSE, start + CHAT_OUTPUT_CONTEXT_OPEN.length); + if (closeAt < 0) break; + const end = closeAt + CHAT_OUTPUT_CONTEXT_CLOSE.length; + const block = text.slice(start, end); + matches.push({ + start, + end, + block, + quote: extractChatOutputContextQuote(block), + }); + from = end; + } + return matches; +} + +export function splitChatOutputContextSegments(text: string): ChatOutputContextSegment[] { + const matches = parseChatOutputContextBlocks(text); + if (!matches.length) return [{ kind: "text", text }]; + const segments: ChatOutputContextSegment[] = []; + let cursor = 0; + for (const match of matches) { + if (match.start > cursor) { + segments.push({ kind: "text", text: text.slice(cursor, match.start) }); + } + segments.push({ kind: "context", quote: match.quote, block: match.block }); + cursor = match.end; + } + if (cursor < text.length) { + segments.push({ kind: "text", text: text.slice(cursor) }); + } + return segments; +} diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 0c7b82a2a..73a87daa1 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -19,7 +19,7 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half โ€” stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` โ€” lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key โ†’ height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` โ†’ `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundJobLine` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat โ€” full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. `WorkingIndicator` is the in-flight turn's status line โ€” ` ยท working for `, plus a `taking longer than usual` marker past `LONG_RUNNING_TURN_SECONDS`. The activity half comes from `resolveWorkingIndicatorLabel`: `ACTIVITY_LABELS` is keyed against the `activity` union in `shared/types/chat.ts` so a new runtime value is a compile error rather than a raw `web_searching` on screen, and an `editing_file` activity is named with its target (`Editing laneService.ts`) by walking back to the most recent unfinished write entry in the turn โ€” `activity` events carry the tool name, not the file. Its elapsed is written imperatively (`textContent` on a ref) rather than through state, so the once-per-second tick never commits a render on the message list. The line swaps a bare `` for an expander `