Skip to content
Open
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
48 changes: 32 additions & 16 deletions web/src/components/chat/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { BrainIcon, ChevronDownIcon, RotateCcwIcon } from "lucide-react";
import { useCallback, useRef } from "react";
import { MarkdownBlock } from "./MarkdownBlock";
import ToolRenderer from "./ToolRenderer";
import {
Expand All @@ -13,6 +14,7 @@ import {
import { useLoopRuntimeExtra, type TurnStats } from "@/useLoopRuntime";
import { cn } from "@/lib/utils";
import ErrorBoundary from "./ErrorBoundary";
import MessageCopyButton from "./MessageCopyButton";

function extractTime(messageId: string | undefined): string {
if (!messageId) return "";
Expand Down Expand Up @@ -49,7 +51,7 @@ function JsonBlock({ content }: { content: string }) {
const formatted = JSON.stringify(parsed, null, 2);
return (
<div className="my-2">
<div className="mb-2 flex items-center gap-2 text-sm text-gray-500">
<div data-copy-ignore className="mb-2 flex select-none items-center gap-2 text-sm text-gray-500">
<svg
className="h-4 w-4"
fill="none"
Expand Down Expand Up @@ -118,6 +120,8 @@ export default function AssistantMessage() {
const isRunning = useAuiState((s) => s.message.status?.type === "running");
const isLast = useAuiState((s) => s.message.isLast);
const showRetry = !isRunning && isLast && !textContent.startsWith("```bash");
const contentRef = useRef<HTMLDivElement | null>(null);
const getPlainText = useCallback(() => textContent, [textContent]);

const time = extractTime(messageId);
// Per-turn stats render once per turn, on the LAST assistant message of the
Expand Down Expand Up @@ -185,7 +189,10 @@ export default function AssistantMessage() {
onOpenChange={setThinkingOpen}
className="group/think my-1 w-full overflow-hidden rounded-md border border-gray-100 bg-gray-50/50"
>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors hover:bg-gray-100/50">
<CollapsibleTrigger
data-copy-ignore
className="flex w-full select-none items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors hover:bg-gray-100/50"
>
<BrainIcon className="h-3 w-3 shrink-0 text-gray-400" />
<span className="text-gray-400">{label}</span>
{running && (
Expand Down Expand Up @@ -237,17 +244,19 @@ export default function AssistantMessage() {
? Array.from(taskMap.values()).find((t) => t.tool_use_id === toolCallId)
: undefined;
return (
<ErrorBoundary name={"ToolRenderer:" + toolName}>
<ToolRenderer
toolName={toolName}
args={args}
result={result}
status={status}
elapsedSeconds={toolProgress?.elapsed_time_seconds}
taskState={taskFromToolUseId}
toolCallId={toolCallId}
/>
</ErrorBoundary>
<div data-copy-ignore>
<ErrorBoundary name={"ToolRenderer:" + toolName}>
<ToolRenderer
toolName={toolName}
args={args}
result={result}
status={status}
elapsedSeconds={toolProgress?.elapsed_time_seconds}
taskState={taskFromToolUseId}
toolCallId={toolCallId}
/>
</ErrorBoundary>
</div>
);
}
default:
Expand Down Expand Up @@ -281,15 +290,22 @@ export default function AssistantMessage() {
/>

{/* Content */}
<div className="w-full text-[13px] md:text-sm text-gray-700">
<div ref={contentRef} className="w-full text-[13px] md:text-sm text-gray-700">
{children}
</div>

{/* Footer: time + retry + per-turn stats (stats only on the last
assistant message of a live turn; retry only when completed) */}
{(time || showRetry || turnStats) && (
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
{(time || showRetry || turnStats || (!isRunning && textContent)) && (
<div data-copy-ignore className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
{time && <span>{time}</span>}
{!isRunning && textContent && (
<MessageCopyButton
contentRef={contentRef}
getPlainText={getPlainText}
className="aui-msg-copy"
/>
)}
{showRetry && (
<button
type="button"
Expand Down
14 changes: 3 additions & 11 deletions web/src/components/chat/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useClipboard } from "@/lib/useClipboard";

interface CopyButtonProps {
content: string;
Expand All @@ -11,15 +11,7 @@ interface CopyButtonProps {
}

export default function CopyButton({ content, className, iconClassName }: CopyButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = () => {
if (!content || copied) return;
navigator.clipboard.writeText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const { copied, copy } = useClipboard(2000);

return (
<Tooltip>
Expand All @@ -28,7 +20,7 @@ export default function CopyButton({ content, className, iconClassName }: CopyBu
type="button"
variant="ghost"
size="icon-xs"
onClick={handleCopy}
onClick={() => void copy(content)}
className={className}
title="Copy message"
>
Expand Down
29 changes: 8 additions & 21 deletions web/src/components/chat/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
CheckIcon,
Expand All @@ -56,6 +55,7 @@ import { FencedSvg, SvgRenderer } from "./SvgRenderer";
import { MermaidBlock } from "./MermaidBlock";
import { PlantUMLBlock } from "./PlantUMLBlock";
import { GraphvizBlock } from "./GraphvizBlock";
import { useClipboard } from "@/lib/useClipboard";

/** Vertical height (px) past which a code block is offered collapsed. */
const COLLAPSE_THRESHOLD_PX = 400;
Expand Down Expand Up @@ -113,18 +113,21 @@ export const MarkdownBlock = memo(MarkdownTextImpl);
const CodeHeader: React.FC<CodeHeaderProps> = ({ language, code }) => {
const key = code ?? "";
const { wrap } = useCodeBlockUi(key);
const { isCopied, copyToClipboard } = useClipboard();
const { isCopied, copyToClipboard } = useClipboard(2000);
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
void copyToClipboard(code);
};

return (
<div className="flex items-center justify-between gap-2 rounded-t-lg border border-gray-700 border-b-0 bg-gray-800 px-3 py-1.5 text-xs">
<div
data-copy-ignore
className="flex select-none items-center justify-between gap-2 rounded-t-lg border border-gray-700 border-b-0 bg-gray-800 px-3 py-1.5 text-xs"
>
<span className="font-medium text-gray-400 lowercase">
{language || "text"}
</span>
<div data-copy-ignore className="flex select-none items-center gap-1">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => toggleWrap(key)}
Expand Down Expand Up @@ -187,22 +190,6 @@ const componentsByLanguage = {
graphviz: { SyntaxHighlighter: GraphvizBlock, CodeHeader: NoCodeHeader },
};

/* ─── Clipboard hook ─── */

function useClipboard({ copiedDuration = 2000 } = {}) {
const [isCopied, setIsCopied] = useState(false);

const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};

return { isCopied, copyToClipboard };
}

/* ─── Shared code-block body: gutter, wrap, collapse ─── */

// The <pre> receives its <code> child as a React element; pull the raw source
Expand Down
83 changes: 83 additions & 0 deletions web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState, type RefObject } from "react";
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { buildClipboardHtml, writeRichClipboard } from "@/lib/clipboard";

interface MessageCopyButtonProps {
contentRef: RefObject<HTMLElement | null>;
getPlainText: () => string;
alwaysVisible?: boolean;
className?: string;
}

export default function MessageCopyButton({
contentRef,
getPlainText,
alwaysVisible,
className,
}: MessageCopyButtonProps) {
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");

const onClick = async () => {
if (status !== "idle") return;

const plainText = getPlainText();
if (!plainText) return;

const html = contentRef.current
? buildClipboardHtml(contentRef.current)
: plainText;

try {
await writeRichClipboard({ plainText, html });
setStatus("copied");
setTimeout(() => setStatus("idle"), 2000);
} catch {
setStatus("failed");
setTimeout(() => setStatus("idle"), 2000);
}
};

const label =
status === "copied"
? "Copied"
: status === "failed"
? "Copy failed"
: "Copy message";

return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
data-copy-ignore
aria-label={label}
className={cn(
"inline-flex h-5 w-5 select-none items-center justify-center rounded text-gray-400 transition-all hover:bg-gray-100 hover:text-gray-600",
!alwaysVisible &&
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
className,
)}
>
{status === "copied" ? (
<CheckIcon className="h-3 w-3 text-emerald-500" />
) : status === "failed" ? (
<XIcon className="h-3 w-3 text-red-500" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
26 changes: 24 additions & 2 deletions web/src/components/chat/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
MessagePrimitive,
useAuiState,
} from "@assistant-ui/react";
import { MarkdownBlock } from "./MarkdownBlock";
import { ChevronDownIcon, ChevronUpIcon, FileText, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import MessageCopyButton from "./MessageCopyButton";

function extractTime(messageId: string | undefined): string {
if (!messageId) return "";
Expand Down Expand Up @@ -54,11 +55,22 @@ function FileCard({ filePath, content }: { filePath: string; content: string })

export default function UserMessage() {
const messageId = useAuiState((s) => s.message.id);
const textContent = useAuiState((s) => {
const parts = s.message.content;
if (!Array.isArray(parts)) return "";
return parts
.filter((p: { type: string }) => p.type === "text")
.map((p: { text?: string }) => p.text ?? "")
.join("");
});
const time = extractTime(messageId);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const getPlainText = useCallback(() => textContent, [textContent]);

const measureRef = (el: HTMLDivElement | null) => {
contentRef.current = el;
if (!el) return;
setNeedsTruncation(el.scrollHeight > 72);
};
Expand Down Expand Up @@ -96,7 +108,17 @@ export default function UserMessage() {
</button>
)}

{time && <div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-gray-400"><span>{time}</span></div>}
{(time || textContent) && (
<div data-copy-ignore className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-gray-400">
{textContent && (
<MessageCopyButton
contentRef={contentRef}
getPlainText={getPlainText}
/>
)}
{time && <span>{time}</span>}
</div>
)}
</MessagePrimitive.Root>
);
}
5 changes: 5 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,8 @@ html, body {
font: inherit;
}
}

/* Keep the latest assistant message copy action visible without affecting older messages. */
[data-role="assistant"]:not(:has(~ [data-role="assistant"])) .aui-msg-copy {
opacity: 1;
}
Loading