diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 37968987f..73542432f 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -31,6 +31,7 @@ import { ChatDesktopConversationHeader, ChatUserTurn } from "@/components/chat/ChatTurn"; +import { ChatCopyButton } from "@/components/chat/ChatCopyButton"; import { Select, SelectContent, @@ -70,6 +71,7 @@ import { agentOperationFence } from "@/services/agentOperationFence"; import { activeAgentThinkingItemId, coalesceAdjacentThinkingItems, + getAgentTurnCopyText, groupAgentTimelineItems, hasAgentUserMessage, hasRenderableThinkingText, @@ -2616,17 +2618,29 @@ function AgentTimeline({ return (
- {turns.map((turn) => { + {turns.map((turn, turnIndex) => { + const copyText = getAgentTurnCopyText(turn); + if (turn.type === "user") { return ( - + : undefined} + > ); } return ( - + + ) : undefined + } + > {turn.items.map((item) => ( ): Message[] { }); } -// Custom hook for copy to clipboard functionality -function useCopyToClipboard(text: string) { - const [isCopied, setIsCopied] = useState(false); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(text); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (error) { - console.error("Failed to copy text:", error); - } - }, [text]); - - return { isCopied, handleCopy }; -} - -// Copy button component with cleaner design -function CopyButton({ text }: { text: string }) { - const { isCopied, handleCopy } = useCopyToClipboard(text); - - return ( - - ); -} - // TTS play button component function TTSButton({ text, @@ -1050,8 +1016,6 @@ const MessageList = memo( onTTSSetupOpen: () => void; onTTSManage: () => void; }) => { - const isMobile = useIsMobile(); - const toolCallsByCallId = useMemo(() => { const toolCalls = new Map(); @@ -1311,10 +1275,7 @@ const MessageList = memo( : undefined} - actionsClassName={ - isMobile ? "opacity-100" : "opacity-0 group-hover/user:opacity-100" - } + actions={userText ? : undefined} > {message.content.map((part, partIdx) => { if ( @@ -1372,7 +1333,7 @@ const MessageList = memo( actions={ textContent ? ( <> - + >(); + + useEffect(() => () => clearTimeout(resetTimerRef.current), []); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setIsCopied(true); + clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => { + setIsCopied(false); + }, 2000); + } catch (error) { + console.error("Failed to copy text:", error); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/chat/ChatTurn.tsx b/frontend/src/components/chat/ChatTurn.tsx index cbf575d8d..0a8ef3caa 100644 --- a/frontend/src/components/chat/ChatTurn.tsx +++ b/frontend/src/components/chat/ChatTurn.tsx @@ -23,13 +23,7 @@ export function MapleChatAvatar() { ); } -export function ChatUserTurn({ - children, - actions, - containerRef, - className, - actionsClassName -}: ChatTurnProps & { actionsClassName?: string }) { +export function ChatUserTurn({ children, actions, containerRef, className }: ChatTurnProps) { return (
{actions ? ( -
+
{actions}
) : null} diff --git a/frontend/src/services/agentTimeline.test.ts b/frontend/src/services/agentTimeline.test.ts index 12766a60f..6051fa3aa 100644 --- a/frontend/src/services/agentTimeline.test.ts +++ b/frontend/src/services/agentTimeline.test.ts @@ -3,21 +3,26 @@ import type { AgentTimelineItem } from "./agentRuntimeService"; import { activeAgentThinkingItemId, coalesceAdjacentThinkingItems, + getAgentTurnCopyText, groupAgentTimelineItems, hasAgentUserMessage, hasRenderableThinkingText, shouldShowAgentAssistantLoader } from "./agentTimeline"; +function item( + id: string, + itemType: AgentTimelineItem["itemType"], + role?: AgentTimelineItem["role"], + text?: string +): AgentTimelineItem { + return { id, itemType, role, text, createdMs: 0, merge: "replace" }; +} + function thinking(id: string, text: string): AgentTimelineItem { return { - id, - itemType: "thinking", - role: "thought", - title: "Thinking", - text, - createdMs: 0, - merge: "replace" + ...item(id, "thinking", "thought", text), + title: "Thinking" }; } @@ -39,22 +44,31 @@ describe("hasRenderableThinkingText", () => { describe("hasAgentUserMessage", () => { test("locks only after a real user message appears", () => { - const item = ( - itemType: AgentTimelineItem["itemType"], - role: AgentTimelineItem["role"] - ): AgentTimelineItem => ({ - id: `${itemType}-${role}`, - itemType, - role, - createdMs: 0, - merge: "replace" - }); - expect(hasAgentUserMessage([])).toBe(false); - expect(hasAgentUserMessage([item("error", "system"), item("message", "assistant")])).toBe( - false - ); - expect(hasAgentUserMessage([item("message", "user")])).toBe(true); + expect( + hasAgentUserMessage([ + item("error", "error", "system"), + item("assistant", "message", "assistant") + ]) + ).toBe(false); + expect(hasAgentUserMessage([item("user", "message", "user")])).toBe(true); + }); +}); + +describe("getAgentTurnCopyText", () => { + test("copies raw user text and only the final assistant message", () => { + const userText = "## Plan\n\nPreserve `raw` markdown 🍁"; + const turns = groupAgentTimelineItems([ + item("leading-tool", "tool", "assistant", "Ignore tool output"), + item("user", "message", "user", userText), + thinking("thought", "Ignore private reasoning"), + item("preamble", "message", "assistant", "Ignore preamble"), + item("tool", "tool", "assistant", "Ignore tool call"), + item("tool-result", "tool", "assistant", "Ignore tool result"), + item("final", "message", "assistant", "**Final** 🍁") + ]); + + expect(turns.map(getAgentTurnCopyText)).toEqual(["", userText, "**Final** 🍁"]); }); }); @@ -119,14 +133,6 @@ describe("activeAgentThinkingItemId", () => { }); describe("groupAgentTimelineItems", () => { - function item( - id: string, - itemType: AgentTimelineItem["itemType"], - role?: AgentTimelineItem["role"] - ): AgentTimelineItem { - return { id, itemType, role, createdMs: 0, merge: "replace" }; - } - test("groups a complete agent response under one assistant turn", () => { const turns = groupAgentTimelineItems([ item("user", "message", "user"), diff --git a/frontend/src/services/agentTimeline.ts b/frontend/src/services/agentTimeline.ts index cd9e203ba..dfc5e4786 100644 --- a/frontend/src/services/agentTimeline.ts +++ b/frontend/src/services/agentTimeline.ts @@ -4,6 +4,17 @@ export type AgentTimelineTurn = | { type: "user"; item: AgentTimelineItem; id: string } | { type: "assistant"; items: AgentTimelineItem[]; id: string }; +export function getAgentTurnCopyText(turn: AgentTimelineTurn): string { + if (turn.type === "user") return turn.item.text ?? ""; + + for (let index = turn.items.length - 1; index >= 0; index -= 1) { + const item = turn.items[index]; + if (item.itemType === "message" && item.role === "assistant") return item.text ?? ""; + } + + return ""; +} + export function hasRenderableThinkingText(text: string | null | undefined): boolean { return Boolean(text?.trim()); }