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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(<AgentChatComposer {...buildComposerProps({
draft: `please ${block} thanks`,
turnActive: false,
})} />);

const chip = await waitFor(() => {
const el = view.container.querySelector<HTMLElement>("[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();

Expand Down
46 changes: 35 additions & 11 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1828,7 +1830,9 @@ export function AgentChatComposer({
const [selectedIosContextId, setSelectedIosContextId] = useState<string | null>(null);
const [selectedAppControlContextId, setSelectedAppControlContextId] = useState<string | null>(null);
const [selectedBuiltInBrowserContextId, setSelectedBuiltInBrowserContextId] = useState<string | null>(null);
const [smartLinkEditorEnabled, setSmartLinkEditorEnabled] = useState(() => findSmartLinks(draft).length > 0);
const [smartLinkEditorEnabled, setSmartLinkEditorEnabled] = useState(
() => findSmartLinks(draft).length > 0 || hasChatOutputContext(draft),
);
const [selectedSmartLinkNode, setSelectedSmartLinkNode] = useState<HTMLElement | null>(null);
const [activeTurnSendMode, setActiveTurnSendMode] = useState<ActiveTurnSendMode>("inline");
const [activeTurnStopMode, setActiveTurnStopMode] = useState<AgentChatStopMode>("stop_and_clear");
Expand All @@ -1837,8 +1841,8 @@ export function AgentChatComposer({
: activeTurnSendMode;

useEffect(() => {
setActiveTurnSendMode("inline");
}, [sessionId, turnActive]);
if (hasChatOutputContext(draft)) setSmartLinkEditorEnabled(true);
}, [draft]);

useEffect(() => {
if (!sessionId) {
Expand Down Expand Up @@ -2498,14 +2502,21 @@ export function AgentChatComposer({
const editor = richEditorRef.current;
if (!editor) return draft;
const parts: string[] = [];
const preservedChipText = new Map<string, string>();
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(node.textContent ?? "");
return;
}
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 (
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -3248,6 +3264,7 @@ export function AgentChatComposer({
}

hydrateMentionChipsInEditor();
hydrateChatOutputContextChipsInEditor(editor);

const isFocusedInsideEditor = document.activeElement === editor;
const insertChipFragment = (chip: HTMLElement) => {
Expand Down Expand Up @@ -4025,18 +4042,18 @@ export function AgentChatComposer({
}

if (event.currentTarget instanceof HTMLDivElement) {
const focusedSmartLink = document.activeElement instanceof HTMLElement
? document.activeElement.closest<HTMLElement>("[data-smart-link-url]")
const focusedChip = document.activeElement instanceof HTMLElement
? document.activeElement.closest<HTMLElement>("[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;
}
}
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ade-chat-context>\nThe user highlighted the following text from your previous output and added it as context:\n\nRetry the lane checkout.\n</ade-chat-context> 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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -799,6 +804,18 @@ function parseLeadingIosContextChips(text: string): { chips: string[]; rest: str
return { chips, rest: text.slice(i) };
}

function ChatOutputContextChip({ quote }: { quote: string }) {
return (
<span
className="mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center rounded-md border border-violet-300/22 bg-violet-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-violet-50/90 align-baseline"
title={quote}
data-testid="user-message-chat-context-chip"
>
{CHAT_OUTPUT_CONTEXT_CHIP_LABEL}
</span>
);
}

function UserMessageSendConfirmations({
event,
}: {
Expand Down Expand Up @@ -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 ? (
<div className="whitespace-pre-wrap break-words text-[length:var(--chat-font-size)] leading-[1.7] text-white">
{event.text}
</div>
) : (
<div className="whitespace-pre-wrap break-words text-[length:var(--chat-font-size)] leading-[1.7] text-white">
<span className="mr-1 inline-flex flex-wrap items-baseline gap-1 align-baseline">
{parsed.chips.map((label, idx) => (
<span
key={`ios-chip-${idx}`}
className="mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center gap-1.5 rounded-md border border-cyan-300/22 bg-cyan-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-cyan-50/85 align-baseline"
title={label}
data-testid="user-message-ios-context-chip"
>
<span className="max-w-[200px] truncate">{label}</span>
</span>
))}
</span>
{parsed.rest}
{parsed.chips.length ? (
<span className="mr-1 inline-flex flex-wrap items-baseline gap-1 align-baseline">
{parsed.chips.map((label, idx) => (
<span
key={`ios-chip-${idx}`}
className="mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center gap-1.5 rounded-md border border-cyan-300/22 bg-cyan-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-cyan-50/85 align-baseline"
title={label}
data-testid="user-message-ios-context-chip"
>
<span className="max-w-[200px] truncate">{label}</span>
</span>
))}
</span>
) : null}
{hasOutputContext
? contextSegments.map((segment, idx) => (
segment.kind === "text"
? <React.Fragment key={`chat-context-text-${idx}`}>{segment.text}</React.Fragment>
: <ChatOutputContextChip key={`chat-context-chip-${idx}`} quote={segment.quote} />
))
: parsed.rest}
</div>
);
// Only the plain prompt body clamps. The hidden-prompt brief and the
Expand Down Expand Up @@ -2807,7 +2834,7 @@ function renderEvent(
/>
) : null}
</div>
<div className="min-w-0">
<div className="min-w-0" data-assistant-output="true">
<MarkdownBlock markdown={event.text} onOpenWorkspacePath={options?.onOpenWorkspacePath} mosaic={options?.mosaic} mosaicScopeKey={envelope.key} />
</div>
</div>
Expand Down Expand Up @@ -6826,6 +6853,7 @@ function AgentChatMessageListMain({
<span>{newRowsSinceDetach > 0 ? `${newRowsSinceDetach} new · jump to latest` : "Jump to latest"}</span>
</button>
) : null}
<AssistantOutputSelectionToolbar rootRef={listRootRef} onAddToChat={onInsertDraft} />
</div>
</ChatWorkspacePathProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ToolbarState | null>(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(
<button
type="button"
data-testid="assistant-output-add-to-chat"
className="ade-assistant-add-to-chat fixed z-[1000] inline-flex items-center gap-1.5 rounded-md border border-violet-300/30 bg-[color:color-mix(in_srgb,var(--chat-panel-bg-strong,#1a1524)_94%,black_6%)] px-2 py-1 font-sans text-[11px] font-medium text-violet-100/90 shadow-[0_12px_32px_rgba(0,0,0,0.42)] backdrop-blur-xl transition-colors hover:bg-violet-500/18"
style={{ left: state.left, top: state.top }}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const block = formatChatOutputContextBlock(state.text);
if (block) onAddToChat(block);
window.getSelection()?.removeAllRanges();
setState(null);
}}
>
<ChatCircleText size={13} weight="bold" aria-hidden />
Add to chat
</button>,
document.body,
);
}
Loading
Loading