Files
Sybil-2/web/src/components/chat/chat-messages-panel.tsx

370 lines
14 KiB
TypeScript
Raw Normal View History

import { useMemo, useState } from "preact/hooks";
import type { JSX } from "preact";
2026-02-14 00:22:19 -08:00
import { cn } from "@/lib/utils";
2026-05-02 19:21:06 -07:00
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api";
2026-02-14 20:51:52 -08:00
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
2026-02-14 00:22:19 -08:00
type Props = {
messages: Message[];
isLoading: boolean;
isSending: boolean;
};
type ToolLogMetadata = {
kind: "tool_call";
2026-05-02 23:48:01 -07:00
toolCallId?: string;
toolName?: string;
2026-06-05 22:20:56 -07:00
status?: "initiated" | "completed" | "failed";
summary?: string;
2026-05-02 23:48:01 -07:00
args?: Record<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
2026-05-02 23:48:01 -07:00
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
return `Tool failed: ${metadata.error.trim()}`;
}
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
if (message.content.trim()) return message.content.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
2026-05-02 23:48:01 -07:00
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
const raw = metadata.toolName?.trim() || message.name?.trim();
if (!raw) return "Tool call";
return raw
.replace(/_/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
.join(" ");
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
if (lowered.includes("url") || lowered.includes("fetch") || lowered.includes("http")) return "fetch";
return "generic";
}
2026-05-02 23:48:01 -07:00
function formatDuration(durationMs: unknown) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
return `${Math.round(durationMs)} ms`;
}
function formatToolTimestamp(...values: Array<string | null | undefined>) {
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
if (!value) return null;
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
}
2026-06-05 22:20:56 -07:00
type ToolCallVisualState = "initiated" | "completed" | "failed";
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
type ToolStackStyle = JSX.CSSProperties & {
"--tool-stack-x"?: string;
"--tool-stack-y"?: string;
"--tool-stack-z"?: string;
"--tool-stack-scale"?: string;
"--tool-stack-opacity"?: string;
"--tool-stack-delay"?: string;
};
const COLLAPSED_TOOL_STACK_LIMIT = 4;
2026-06-05 22:20:56 -07:00
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
if (metadata.status === "failed") return "failed";
if (metadata.status === "initiated") return "initiated";
return "completed";
}
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
2026-05-02 23:48:01 -07:00
return [
2026-06-05 22:20:56 -07:00
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
2026-05-02 23:48:01 -07:00
formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
]
.filter(Boolean)
.join(" • ");
}
function buildMessageRenderItems(messages: Message[]) {
const items: MessageRenderItem[] = [];
let toolRun: Message[] = [];
const flushToolRun = () => {
if (!toolRun.length) return;
if (toolRun.length === 1) {
items.push({ kind: "message", message: toolRun[0] });
} else {
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
}
toolRun = [];
};
for (const message of messages) {
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
toolRun.push(message);
continue;
}
flushToolRun();
items.push({ kind: "message", message });
}
flushToolRun();
return items;
}
function getToolStackStyle(depth: number, totalVisible: number): ToolStackStyle {
return {
"--tool-stack-x": `${depth * 9}px`,
"--tool-stack-y": `${depth * 8}px`,
"--tool-stack-z": `${depth * -36}px`,
"--tool-stack-scale": `${Math.max(0.88, 1 - depth * 0.035)}`,
"--tool-stack-opacity": `${Math.max(0.48, 1 - depth * 0.15)}`,
"--tool-stack-delay": `${depth * 44}ms`,
zIndex: totalVisible - depth,
};
}
function getExpandedToolStyle(index: number): ToolStackStyle {
return {
"--tool-stack-delay": `${Math.min(index, 6) * 34}ms`,
};
}
function ToolCallCard({
message,
className,
style,
}: {
message: Message;
className?: string;
style?: JSX.CSSProperties;
}) {
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (!toolLogMetadata) return null;
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const toolState = getToolVisualState(toolLogMetadata);
const isFailed = toolState === "failed";
const isInitiated = toolState === "initiated";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return (
<div
className={cn(
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]",
className
)}
style={style}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
: isInitiated
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
);
}
function ToolCallStack({
groupKey,
messages,
expanded,
onToggle,
}: {
groupKey: string;
messages: Message[];
expanded: boolean;
onToggle: (groupKey: string) => void;
}) {
const visibleStackMessages = messages.slice(-COLLAPSED_TOOL_STACK_LIMIT).reverse();
const hiddenCount = Math.max(0, messages.length - visibleStackMessages.length);
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
if (expanded) {
return (
<div className="flex justify-start">
<div className="relative flex w-full max-w-[85%] flex-col gap-2.5 pr-5">
<button
type="button"
className="tool-call-stack-toggle absolute -right-3 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
aria-expanded="true"
aria-label={`Collapse ${countLabel}`}
title={`Collapse ${countLabel}`}
onClick={() => onToggle(groupKey)}
>
<ChevronUp className="h-4 w-4" />
</button>
{messages.map((message, index) => (
<ToolCallCard
key={message.id}
message={message}
className="tool-call-stack-expanded-card w-full max-w-full"
style={getExpandedToolStyle(index)}
/>
))}
</div>
</div>
);
}
return (
<div className="flex justify-start">
<div className="tool-call-stack-shell relative inline-grid w-full max-w-[85%] min-w-0 pb-6 pr-9">
{visibleStackMessages.map((message, index) => (
<ToolCallCard
key={message.id}
message={message}
className={cn("tool-call-stack-card col-start-1 row-start-1 w-full max-w-full", index > 0 && "pointer-events-none")}
style={getToolStackStyle(index, visibleStackMessages.length)}
/>
))}
{hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute -right-3 top-1/2 z-20 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
aria-expanded="false"
aria-label={`Expand ${countLabel}`}
title={`Expand ${countLabel}`}
onClick={() => onToggle(groupKey)}
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
</div>
);
}
2026-02-14 00:22:19 -08:00
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
2026-02-14 21:15:54 -08:00
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
const toggleToolGroup = (groupKey: string) => {
setExpandedToolGroups((current) => {
const next = new Set(current);
if (next.has(groupKey)) next.delete(groupKey);
else next.add(groupKey);
return next;
});
};
2026-02-14 21:07:31 -08:00
2026-02-14 00:22:19 -08:00
return (
<>
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
2026-05-02 15:44:31 -07:00
<div className="mx-auto max-w-4xl space-y-6">
{renderItems.map((item) => {
if (item.kind === "tool_group") {
return (
<ToolCallStack
key={`tool-group-${item.key}`}
groupKey={item.key}
messages={item.messages}
expanded={expandedToolGroups.has(item.key)}
onToggle={toggleToolGroup}
/>
);
}
const { message } = item;
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) {
return (
<div key={message.id} className="flex justify-start">
<ToolCallCard message={message} className="max-w-[85%]" />
</div>
);
}
2026-02-14 00:22:19 -08:00
const isUser = message.role === "user";
2026-02-14 21:15:54 -08:00
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
2026-05-02 19:21:06 -07:00
const attachments = getMessageAttachments(message.metadata);
2026-02-14 00:22:19 -08:00
return (
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
2026-05-02 19:21:06 -07:00
"max-w-[85%] space-y-3",
2026-05-02 15:44:31 -07:00
isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
2026-02-14 00:22:19 -08:00
)}
>
2026-05-02 19:21:06 -07:00
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
2026-02-14 20:51:52 -08:00
{isPendingAssistant ? (
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
2026-02-14 21:07:31 -08:00
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
2026-02-14 20:51:52 -08:00
</span>
2026-05-02 19:21:06 -07:00
) : message.content.trim() ? (
2026-02-14 20:51:52 -08:00
<MarkdownContent
markdown={message.content}
2026-05-02 15:44:31 -07:00
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
2026-02-14 20:51:52 -08:00
/>
2026-05-02 19:21:06 -07:00
) : null}
2026-02-14 00:22:19 -08:00
</div>
</div>
);
})}
2026-02-14 21:07:31 -08:00
{isSending && !hasPendingAssistant ? (
<div className="flex justify-start">
2026-05-02 15:44:31 -07:00
<div className="max-w-[85%] text-base leading-7 text-violet-50">
2026-02-14 21:07:31 -08:00
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
</div>
</div>
) : null}
2026-02-14 00:22:19 -08:00
</div>
</>
);
}