Pipeline/frontend/src/components/claude/SessionMessageThread.tsx

211 lines
8.3 KiB
TypeScript

"use client";
import { useState } from "react";
import {
Bot,
Check,
ChevronDown,
Clipboard,
MessageSquare,
Sparkles,
UserRound,
} from "lucide-react";
import { ToolCallBlock } from "@/components/claude/ToolCallBlock";
import { type SessionMessage } from "@/lib/api/agent-sessions";
import { formatTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type SessionMessageThreadProps = {
messages: SessionMessage[];
};
async function copyText(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard)
return;
await navigator.clipboard.writeText(value);
onCopied();
}
function messageText(message: SessionMessage) {
return message.text_blocks.map((block) => block.text).join("\n\n");
}
function tokenTotal(message: SessionMessage) {
if (!message.tokens) return null;
return (
message.tokens.input +
message.tokens.output +
message.tokens.cache_read +
message.tokens.cache_write
);
}
export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
const [openThinking, setOpenThinking] = useState<Record<string, boolean>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const markCopied = (id: string) => {
setCopiedId(id);
window.setTimeout(() => setCopiedId(null), 1400);
};
if (messages.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center">
<MessageSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No conversation messages
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
This session was found, but there are no displayable user or assistant
turns in the selected page.
</p>
</div>
);
}
return (
<div className="space-y-5">
{messages.map((message, index) => {
const isAssistant = message.role === "assistant";
const text = messageText(message);
const thinkingOpen = openThinking[message.uuid] ?? false;
const tokens = tokenTotal(message);
return (
<article
key={message.uuid}
id={`message-${message.uuid}`}
className={cn(
"scroll-mt-32 rounded-2xl border shadow-sm",
isAssistant
? "border-cyan-400/15 bg-[linear-gradient(180deg,rgba(8,145,178,0.08),rgba(8,145,178,0.02))]"
: "border-[color:var(--border)] bg-[color:var(--surface)]",
)}
>
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-[color:var(--border)] px-4 py-3 md:px-5">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1",
isAssistant
? "bg-cyan-500/15 text-cyan-300 ring-cyan-400/20"
: "bg-violet-500/15 text-violet-300 ring-violet-400/20",
)}
>
{isAssistant ? (
<Bot className="h-4 w-4" />
) : (
<UserRound className="h-4 w-4" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold text-[color:var(--text)]">
{isAssistant ? "Assistant" : "User"}
</h2>
<span className="rounded-full border border-[color:var(--border)] px-2 py-0.5 text-[11px] font-semibold text-[color:var(--text-muted)]">
#{index + 1}
</span>
{message.model ? (
<span className="max-w-[220px] truncate rounded-full bg-[color:var(--surface-muted)] px-2 py-0.5 font-mono text-[11px] text-[color:var(--text-muted)]">
{message.model}
</span>
) : null}
</div>
<p className="mt-0.5 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(message.timestamp)}
{tokens !== null
? ` · ${tokens.toLocaleString()} tokens`
: ""}
</p>
</div>
</div>
{text ? (
<button
type="button"
className="inline-flex h-8 items-center gap-2 rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-semibold text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
onClick={() => copyText(text, () => markCopied(message.uuid))}
>
{copiedId === message.uuid ? (
<Check className="h-3.5 w-3.5" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
{copiedId === message.uuid ? "Copied" : "Copy"}
</button>
) : null}
</header>
<div className="space-y-4 px-4 py-4 md:px-5">
{message.thinking_blocks.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-amber-400/20 bg-amber-500/10">
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-amber-100 transition hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
aria-expanded={thinkingOpen}
onClick={() =>
setOpenThinking((value) => ({
...value,
[message.uuid]: !thinkingOpen,
}))
}
>
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Thinking
</span>
<ChevronDown
className={cn(
"h-4 w-4 transition",
thinkingOpen && "rotate-180",
)}
/>
</button>
{thinkingOpen ? (
<div className="space-y-3 border-t border-amber-400/20 px-4 py-3">
{message.thinking_blocks.map((block, blockIndex) => (
<p
key={`${message.uuid}-thinking-${blockIndex}`}
className="whitespace-pre-wrap break-words text-sm italic leading-7 text-amber-50/90"
>
{block.text}
{block.truncated ? " [truncated]" : ""}
</p>
))}
</div>
) : null}
</div>
) : null}
{message.text_blocks.map((block, blockIndex) => (
<div
key={`${message.uuid}-text-${blockIndex}`}
className="prose prose-invert max-w-none whitespace-pre-wrap break-words text-[15px] leading-7 text-[color:var(--text)]"
>
{block.text}
{block.truncated ? (
<span className="ml-2 rounded-full bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--text-muted)]">
truncated
</span>
) : null}
</div>
))}
{message.tool_uses.length > 0 ? (
<div className="space-y-3">
{message.tool_uses.map((tool) => (
<ToolCallBlock key={tool.tool_use_id} tool={tool} />
))}
</div>
) : null}
</div>
</article>
);
})}
</div>
);
}