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
20 changes: 17 additions & 3 deletions frontend/src/components/AgentMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
ChatDesktopConversationHeader,
ChatUserTurn
} from "@/components/chat/ChatTurn";
import { ChatCopyButton } from "@/components/chat/ChatCopyButton";
import {
Select,
SelectContent,
Expand Down Expand Up @@ -70,6 +71,7 @@ import { agentOperationFence } from "@/services/agentOperationFence";
import {
activeAgentThinkingItemId,
coalesceAdjacentThinkingItems,
getAgentTurnCopyText,
groupAgentTimelineItems,
hasAgentUserMessage,
hasRenderableThinkingText,
Expand Down Expand Up @@ -2616,17 +2618,29 @@ function AgentTimeline({

return (
<div className="space-y-1">
{turns.map((turn) => {
{turns.map((turn, turnIndex) => {
const copyText = getAgentTurnCopyText(turn);

if (turn.type === "user") {
return (
<ChatUserTurn key={turn.id}>
<ChatUserTurn
key={turn.id}
actions={copyText ? <ChatCopyButton text={copyText} /> : undefined}
>
<Markdown content={turn.item.text || ""} />
</ChatUserTurn>
);
}

return (
<ChatAssistantTurn key={turn.id}>
<ChatAssistantTurn
key={turn.id}
actions={
copyText && !(isRunActive && turnIndex === turns.length - 1) ? (
<ChatCopyButton text={copyText} />
) : undefined
}
>
{turn.items.map((item) => (
<AgentAssistantItem
key={item.id}
Expand Down
45 changes: 3 additions & 42 deletions frontend/src/components/UnifiedChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
import { flushSync } from "react-dom";
import {
ArrowUp,
Copy,
Check,
Plus,
Image,
Expand Down Expand Up @@ -44,6 +43,7 @@ import {
ChatDesktopConversationHeader,
ChatUserTurn
} from "@/components/chat/ChatTurn";
import { ChatCopyButton } from "@/components/chat/ChatCopyButton";
import { ModelSelector } from "@/components/ModelSelector";
import { useLocalState } from "@/state/useLocalState";
import { useOpenSecret } from "@opensecret/react";
Expand Down Expand Up @@ -684,40 +684,6 @@ function convertItemsToMessages(items: Array<unknown>): 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 (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCopy}
aria-label={isCopied ? "Copied" : "Copy to clipboard"}
>
{isCopied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}

// TTS play button component
function TTSButton({
text,
Expand Down Expand Up @@ -1050,8 +1016,6 @@ const MessageList = memo(
onTTSSetupOpen: () => void;
onTTSManage: () => void;
}) => {
const isMobile = useIsMobile();

const toolCallsByCallId = useMemo(() => {
const toolCalls = new Map<string, ToolCallItem>();

Expand Down Expand Up @@ -1311,10 +1275,7 @@ const MessageList = memo(
<ChatUserTurn
key={group.id}
containerRef={groupIndex === 0 ? firstMessageRef : undefined}
actions={userText ? <CopyButton text={userText} /> : undefined}
actionsClassName={
isMobile ? "opacity-100" : "opacity-0 group-hover/user:opacity-100"
}
actions={userText ? <ChatCopyButton text={userText} /> : undefined}
>
{message.content.map((part, partIdx) => {
if (
Expand Down Expand Up @@ -1372,7 +1333,7 @@ const MessageList = memo(
actions={
textContent ? (
<>
<CopyButton text={textContent} />
<ChatCopyButton text={textContent} />
<TTSButton
text={textContent}
messageId={group.id}
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/components/chat/ChatCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";

export function ChatCopyButton({ text }: { text: string }) {
const [isCopied, setIsCopied] = useState(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout>>();

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 (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCopy}
aria-label={isCopied ? "Copied" : "Copy to clipboard"}
>
{isCopied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}
10 changes: 2 additions & 8 deletions frontend/src/components/chat/ChatTurn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
ref={containerRef}
Expand All @@ -41,7 +35,7 @@ export function ChatUserTurn({
</div>
</div>
{actions ? (
<div className={cn("flex justify-end pr-1 pt-1 transition-opacity", actionsClassName)}>
<div className="flex justify-end pr-1 pt-1 opacity-100 transition-opacity md:opacity-0 md:group-hover/user:opacity-100 md:group-focus-within/user:opacity-100 landscape-short:opacity-100">
{actions}
</div>
) : null}
Expand Down
66 changes: 36 additions & 30 deletions frontend/src/services/agentTimeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};
}

Expand All @@ -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** 🍁"]);
});
});

Expand Down Expand Up @@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/services/agentTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Loading