211 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|