TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { AlertTriangle, BookmarkPlus, Brain, Check, ChevronDown, ChevronRight, Columns3, Copy, ExternalLink, FileText, FileDown, GitBranch, Globe, Loader2, MoreHorizontal, Pencil, Quote, RefreshCw, Shuffle, StepForward, Trash2, Wrench, X } from "lucide-react";4import type { PublicMessage, StoredPart, PolyModel, StoredMessageError } from "@/lib/client/types";5import { Markdown } from "@/components/markdown/markdown";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { Button } from "@/components/ui/button";8import { Textarea } from "@/components/ui/input";9import { Tooltip } from "@/components/ui/tooltip";10import { Badge } from "@/components/ui/badge";11import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";12import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";13import { useIsMobile, useLongPress } from "@/lib/client/hooks";14import { deprecationNotice } from "@/lib/chat/deprecation";15import { MessageError } from "./message-error";16import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";17import { PROVIDERS } from "@/lib/client/providers";1819export interface LiveState {20 text: string;21 reasoning: string;22 tools: { id: string; name: string; args: string; result?: unknown; isError?: boolean; durationMs?: number }[];23 serverTools: { name: string; status: string }[];24 citations: { url?: string; title?: string; snippet?: string }[];25 startedAt: number;26 firstTokenAt?: number;27}2829export interface MessageActions {30 onCopy?: (text: string) => void;31 onEdit?: (m: PublicMessage, text: string) => void;32 /** Retry with the same model. */33 onRegenerate?: (m: PublicMessage) => void;34 /** Opens the model picker, then regenerates with the chosen model. */35 onRegenerateWith?: (m: PublicMessage) => void;36 onRetry?: (m: PublicMessage) => void;37 onContinue?: (m: PublicMessage) => void;38 onBranch?: (m: PublicMessage) => void;39 onDelete?: (m: PublicMessage) => void;40 onCompare?: (m: PublicMessage) => void;41 onQuote?: (m: PublicMessage) => void;42 onSaveAsPrompt?: (m: PublicMessage) => void;43 onExport?: (m: PublicMessage) => void;44 /** "Switch to <replacement>" from the deprecated-model notice. */45 onSwitchModel?: (key: string) => void;46}4748interface Props {49 message: PublicMessage;50 model?: PolyModel;51 /** Suggested replacement when `model` is deprecated / retiring (computed by the parent). */52 replacement?: PolyModel | null;53 live?: LiveState | null;54 isLast?: boolean;55 wrapCode?: boolean;56 showReasoning?: boolean;57 showCosts?: boolean;58 actions?: MessageActions;59 busy?: boolean;60 /** Request id of the turn that produced this message (for the error details sheet). */61 requestId?: string | null;62 /** Epoch ms when the error was received (drives the retry countdown). */63 errorAt?: number;64}6566type Item = { key: string; label: string; icon: React.ReactNode; onSelect: () => void; destructive?: boolean; hidden?: boolean };6768export const MessageItem = React.memo(function MessageItem({ message: m, model, replacement, live, isLast, wrapCode, showReasoning = true, showCosts = true, actions, busy, requestId, errorAt }: Props) {69 const isUser = m.role === "user";70 const streaming = m.status === "streaming" && Boolean(live);71 const isMobile = useIsMobile();72 const [editing, setEditing] = React.useState(false);73 const [draft, setDraft] = React.useState(m.content);74 const [copied, setCopied] = React.useState(false);75 const [sheetOpen, setSheetOpen] = React.useState(false);76 const [detailsOpen, setDetailsOpen] = React.useState(false);77 const [now] = React.useState(() => Date.now());7879 const text = streaming ? live!.text : m.content;80 const parts = m.parts as StoredPart[];81 const reasoningParts = parts.filter((p): p is Extract<StoredPart, { type: "reasoning" }> => p.type === "reasoning");82 const reasoning = streaming ? live!.reasoning : reasoningParts.map((p) => p.text).join("\n\n");83 const attachments = parts.filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment");84 const toolCalls = streaming ? live!.tools : parts.filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call").map((p) => ({ id: p.id, name: p.name, args: p.argumentsText ?? JSON.stringify(p.arguments), result: p.result, isError: p.isError, durationMs: p.durationMs }));85 const citations = streaming ? live!.citations : parts.filter((p): p is Extract<StoredPart, { type: "citation" }> => p.type === "citation");86 const serverTools = streaming ? live!.serverTools : parts.filter((p): p is Extract<StoredPart, { type: "server-tool" }> => p.type === "server-tool");87 const refusal = parts.find((p): p is Extract<StoredPart, { type: "refusal" }> => p.type === "refusal");88 const usage = m.usage as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null;89 const settings = (m as { settings?: Record<string, unknown> | null }).settings ?? null;90 const tps = m.latencyMs && usage?.outputTokens ? Math.round((usage.outputTokens / Math.max(1, m.latencyMs - (m.ttftMs ?? 0))) * 1000) : null;91 const notice = deprecationNotice(model, now);9293 const copy = React.useCallback(async () => {94 await navigator.clipboard.writeText(text).catch(() => {});95 actions?.onCopy?.(text);96 setCopied(true);97 setTimeout(() => setCopied(false), 1400);98 }, [text, actions]);99100 // --- action list (shared by the hover toolbar, the "more" menu and the long-press sheet) ----------101 const items: Item[] = isUser102 ? [103 { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? <Check className="text-success" /> : <Copy />, onSelect: copy },104 { key: "edit", label: "Edit & resend", icon: <Pencil />, onSelect: () => setEditing(true), hidden: !actions?.onEdit || busy },105 { key: "quote", label: "Quote", icon: <Quote />, onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote },106 { key: "branch", label: "Branch from here", icon: <GitBranch />, onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch },107 { key: "prompt", label: "Save as prompt", icon: <BookmarkPlus />, onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt },108 { key: "export", label: "Export message", icon: <FileDown />, onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport },109 { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy },110 ]111 : [112 { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? <Check className="text-success" /> : <Copy />, onSelect: copy },113 { key: "retry", label: "Retry", icon: <RefreshCw />, onSelect: () => actions?.onRegenerate?.(m), hidden: !actions?.onRegenerate || busy },114 { key: "retry-with", label: "Retry with another model", icon: <Shuffle />, onSelect: () => actions?.onRegenerateWith?.(m), hidden: !actions?.onRegenerateWith || busy },115 { key: "continue", label: "Continue", icon: <StepForward />, onSelect: () => actions?.onContinue?.(m), hidden: !actions?.onContinue || !isLast || busy || !(m.finishReason === "length" || m.status === "stopped") },116 { key: "compare", label: "Compare this response", icon: <Columns3 />, onSelect: () => actions?.onCompare?.(m), hidden: !actions?.onCompare || busy },117 { key: "branch", label: "Branch from here", icon: <GitBranch />, onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch },118 { key: "quote", label: "Quote", icon: <Quote />, onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote },119 { key: "prompt", label: "Save as prompt", icon: <BookmarkPlus />, onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt },120 { key: "export", label: "Export message", icon: <FileDown />, onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport },121 { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy },122 ];123 const visible = items.filter((i) => !i.hidden);124 const primaryKeys = isUser ? ["copy", "edit", "branch"] : ["copy", "retry", "continue", "compare", "branch"];125 const primary = visible.filter((i) => primaryKeys.includes(i.key));126 const more = visible.filter((i) => !primaryKeys.includes(i.key));127128 const longPress = useLongPress({ onLongPress: () => setSheetOpen(true), disabled: !isMobile || streaming || editing || visible.length === 0 });129130 const toolbar = !streaming ? (131 <div className={cn("flex items-center gap-0.5", isMobile ? "" : "hover-reveal")}>132 {primary.map((i) => (133 <IconBtn key={i.key} label={i.label} onClick={i.onSelect}>134 {i.icon}135 </IconBtn>136 ))}137 {more.length ? (138 isMobile ? (139 <IconBtn label="More actions" onClick={() => setSheetOpen(true)}>140 <MoreHorizontal />141 </IconBtn>142 ) : (143 <DropdownMenu>144 <DropdownMenuTrigger asChild>145 <button className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label="More actions">146 <MoreHorizontal />147 </button>148 </DropdownMenuTrigger>149 <DropdownMenuContent align={isUser ? "end" : "start"} className="min-w-[220px]">150 {more.map((i, idx) => (151 <React.Fragment key={i.key}>152 {i.destructive && idx > 0 ? <DropdownMenuSeparator /> : null}153 <DropdownMenuItem destructive={i.destructive} onSelect={() => setTimeout(i.onSelect, 10)}>154 {i.icon} {i.label}155 </DropdownMenuItem>156 </React.Fragment>157 ))}158 </DropdownMenuContent>159 </DropdownMenu>160 )161 ) : null}162 </div>163 ) : null;164165 const sheet = isMobile ? (166 <ActionSheet open={sheetOpen} onOpenChange={setSheetOpen} items={toSheetItems(visible)} title={isUser ? "Your message" : model?.displayName ?? "Response"}>167 <p className="line-clamp-3 rounded-xl bg-bg-subtle px-3 py-2 text-[13px] text-fg-muted">{text || "(no text)"}</p>168 </ActionSheet>169 ) : null;170171 if (isUser) {172 return (173 <div id={m.id} className="group flex w-full justify-end px-3 sm:px-0">174 <div className="flex max-w-[92%] flex-col items-end gap-1.5 sm:max-w-[78%]">175 {attachments.length ? (176 <div className="flex flex-wrap justify-end gap-1.5">177 {attachments.map((a) => (178 <AttachmentChip key={a.attachmentId} a={a} />179 ))}180 </div>181 ) : null}182 {editing ? (183 <div className="w-full min-w-[min(280px,80vw)] space-y-2 rounded-2xl border border-border bg-bg-elevated p-2">184 <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[80px] border-0 shadow-none focus:ring-0" autoFocus />185 <div className="flex justify-end gap-2">186 <Button variant="ghost" size="xs" onClick={() => setEditing(false)}>187 Cancel188 </Button>189 <Button190 size="xs"191 onClick={() => {192 setEditing(false);193 if (draft.trim() && draft !== m.content) actions?.onEdit?.(m, draft);194 }}195 >196 Save & resend197 </Button>198 </div>199 </div>200 ) : (201 <div {...longPress} className="select-text whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-relaxed [-webkit-touch-callout:none]">202 {m.content}203 </div>204 )}205 <div className="flex items-center gap-0.5 text-[11px] text-fg-subtle">206 {toolbar}207 <span className="ml-1">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>208 </div>209 </div>210 {sheet}211 </div>212 );213 }214215 const provider = m.provider ?? model?.provider;216 const modelName = model?.displayName ?? m.modelKey?.split("/").slice(1).join("/") ?? "model";217 const waiting = streaming && !live!.text && !live!.reasoning && live!.tools.length === 0 && live!.serverTools.length === 0;218 const err = m.error as StoredMessageError | null;219220 return (221 <div id={m.id} className="group flex w-full gap-3 px-3 sm:px-0">222 <div className="mt-1 hidden size-7 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated sm:flex">223 <ProviderIcon provider={provider} size={15} />224 </div>225 <div className="min-w-0 flex-1 space-y-2">226 <div className="flex flex-wrap items-center gap-1.5 text-[12px] text-fg-muted">227 <ProviderIcon provider={provider} size={13} className="sm:hidden" />228 <span className="font-medium text-fg">{modelName}</span>229 {provider ? <span className="text-fg-subtle">· {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider}</span> : null}230 {m.status === "stopped" ? <Badge variant="warning">Stopped</Badge> : null}231 {m.status === "error" ? <Badge variant="danger">Failed</Badge> : null}232 {m.finishReason === "length" ? <Badge variant="warning">Cut off (max tokens)</Badge> : null}233 {notice ? <Badge variant={notice.kind === "deprecated" ? "danger" : "warning"}>{notice.kind === "deprecated" ? "Deprecated" : `Retires ${notice.shutdownDate}`}</Badge> : null}234 </div>235236 {notice && isLast && !streaming && replacement && actions?.onSwitchModel ? (237 <div className="flex flex-wrap items-center gap-2 rounded-xl bg-warning-soft px-3 py-2 text-[12.5px] text-warning">238 <AlertTriangle className="size-3.5 shrink-0" />239 <span className="min-w-0 flex-1">240 {modelName} is {notice.kind === "deprecated" ? "deprecated" : `retiring on ${notice.shutdownDate}`}.241 </span>242 <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={() => actions.onSwitchModel?.(replacement.key)}>243 Switch to {replacement.displayName}244 </Button>245 </div>246 ) : null}247248 {(reasoning || (streaming && live!.reasoning)) && showReasoning ? <ReasoningBlock text={reasoning} streaming={streaming && !live!.text} durationMs={reasoningParts[0]?.durationMs} /> : null}249250 {serverTools.length ? (251 <div className="flex flex-wrap gap-1.5">252 {dedupeServerTools(serverTools).map((t, i) => (253 <Badge key={i} variant="info" className="gap-1">254 {t.name === "web_search" ? <Globe /> : <Wrench />} {t.name.replace(/_/g, " ")} {t.status === "started" ? <Loader2 className="animate-spin" /> : null}255 </Badge>256 ))}257 </div>258 ) : null}259260 {toolCalls.map((t) => (261 <ToolCallCard key={t.id} call={t} />262 ))}263264 {waiting ? (265 <div className="flex items-center gap-2 py-1 text-[13px] text-fg-muted">266 <span className="inline-flex gap-1">267 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle" />268 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:200ms]" />269 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:400ms]" />270 </span>271 {model?.capabilities.reasoning ? "Thinking…" : "Waiting for the model…"}272 </div>273 ) : null}274275 {text ? (276 <div {...longPress} className="select-text [-webkit-touch-callout:none]">277 <Markdown content={text} wrap={wrapCode} streaming={streaming} />278 </div>279 ) : null}280281 {refusal ? (282 <div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning-soft px-3 py-2 text-[13px] text-warning">283 <AlertTriangle className="mt-0.5 size-4 shrink-0" />284 <div>285 The provider declined this request{refusal.category ? ` (${refusal.category})` : ""}.{refusal.explanation ? ` ${refusal.explanation}` : ""}286 </div>287 </div>288 ) : null}289290 {err && (m.status === "error" || (m.status === "stopped" && err.code !== "CANCELLED")) ? (291 <MessageError error={err} provider={err.provider ?? provider} requestId={requestId} since={errorAt} onRetry={actions?.onRetry && !busy ? () => actions.onRetry?.(m) : undefined} onSwitchModel={actions?.onRegenerateWith && !busy ? () => actions.onRegenerateWith?.(m) : undefined} />292 ) : null}293294 {citations.length ? <Citations items={citations} /> : null}295296 {!streaming ? (297 <div className="flex flex-wrap items-center gap-x-1 gap-y-1 pt-0.5 text-[11.5px] text-fg-subtle">298 {toolbar}299 <span className="ml-1 tabular-nums">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>300 {usage ? (301 <>302 <Dot />303 <span className="tabular-nums">304 {formatTokens(usage.inputTokens ?? 0)} in · {formatTokens(usage.outputTokens ?? 0)} out305 </span>306 </>307 ) : null}308 {showCosts && m.costUsd !== null && m.costUsd !== undefined ? (309 <>310 <Dot />311 <span className="tabular-nums">≈ {formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}</span>312 </>313 ) : null}314 {usage || m.latencyMs ? (315 <button type="button" onClick={() => setDetailsOpen((o) => !o)} className="tap ml-1 inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-expanded={detailsOpen} aria-label="Toggle message details">316 Details {detailsOpen ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}317 </button>318 ) : null}319 </div>320 ) : null}321322 {detailsOpen && !streaming ? (323 <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-xl bg-bg-subtle/70 px-3 py-2.5 text-[12px] sm:grid-cols-3 md:grid-cols-4">324 <Meta k="Model" v={m.modelKey?.split("/").slice(1).join("/") ?? modelName} mono />325 <Meta k="Provider" v={provider ? PROVIDERS[provider as keyof typeof PROVIDERS]?.name ?? provider : "—"} />326 <Meta k="Input tokens" v={usage?.inputTokens !== undefined ? formatTokens(usage.inputTokens) : "—"} />327 <Meta k="Output tokens" v={usage?.outputTokens !== undefined ? formatTokens(usage.outputTokens) : "—"} />328 <Meta k="Reasoning tokens" v={usage?.reasoningTokens ? formatTokens(usage.reasoningTokens) : "—"} />329 <Meta k="Cached tokens" v={usage?.cachedInputTokens ? formatTokens(usage.cachedInputTokens) : "—"} />330 <Meta k="Cost" v={m.costUsd !== null && m.costUsd !== undefined ? `≈ ${formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}` : "—"} />331 <Meta k="Time to first token" v={formatMs(m.ttftMs)} />332 <Meta k="Total latency" v={formatMs(m.latencyMs)} />333 <Meta k="Speed" v={tps ? `${tps} tok/s` : "—"} />334 <Meta k="Reasoning effort" v={typeof settings?.reasoningEffort === "string" ? String(settings.reasoningEffort) : settings?.thinkingBudget ? `${formatTokens(settings.thinkingBudget as number)} budget` : "—"} />335 <Meta k="Finish reason" v={m.finishReason ?? "—"} mono />336 </dl>337 ) : null}338 </div>339 {sheet}340 </div>341 );342});343344function toSheetItems(items: Item[]): ActionSheetItem[] {345 return items.map((i) => ({ key: i.key, label: i.label, icon: i.icon, onSelect: i.onSelect, destructive: i.destructive }));346}347348function Dot() {349 return <span className="text-border-strong">·</span>;350}351352function Meta({ k, v, mono }: { k: string; v: string; mono?: boolean }) {353 return (354 <div className="min-w-0">355 <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{k}</dt>356 <dd className={cn("truncate tabular-nums text-fg", mono && "font-mono text-[11.5px]")} title={v}>357 {v}358 </dd>359 </div>360 );361}362363function IconBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {364 return (365 <Tooltip content={label}>366 <button onClick={onClick} className="tap rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label={label}>367 {children}368 </button>369 </Tooltip>370 );371}372373function ReasoningBlock({ text, streaming, durationMs }: { text: string; streaming: boolean; durationMs?: number }) {374 const [open, setOpen] = React.useState(streaming);375 React.useEffect(() => {376 if (!streaming) {377 // collapse automatically once the answer starts378 // eslint-disable-next-line react-hooks/set-state-in-effect379 setOpen(false);380 }381 }, [streaming]);382 const ref = React.useRef<HTMLDivElement>(null);383 React.useEffect(() => {384 if (open && streaming && ref.current) ref.current.scrollTop = ref.current.scrollHeight;385 }, [text, open, streaming]);386 return (387 <div className="rounded-lg border border-border bg-bg-subtle/60">388 <button onClick={() => setOpen((o) => !o)} className="flex min-h-[36px] w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg">389 <Brain className={cn("size-3.5", streaming && "animate-pulse-soft text-accent")} />390 <span className="font-medium">{streaming ? "Thinking…" : "Reasoning"}</span>391 {durationMs ? <span className="text-fg-subtle">· {formatMs(durationMs)}</span> : null}392 <span className="ml-auto">{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}</span>393 </button>394 {open ? (395 <div ref={ref} className="max-h-72 overflow-y-auto border-t border-border px-3 py-2 text-[13px] leading-relaxed text-fg-muted scrollbar-thin">396 <Markdown content={text} streaming={streaming} className="text-[13px] text-fg-muted [&_p]:text-fg-muted" />397 </div>398 ) : null}399 </div>400 );401}402403function ToolCallCard({ call }: { call: { id: string; name: string; args: string; result?: unknown; isError?: boolean; durationMs?: number } }) {404 const [open, setOpen] = React.useState(false);405 const pending = call.result === undefined;406 return (407 <div className="rounded-lg border border-border bg-bg-subtle/60 text-[12.5px]">408 <button onClick={() => setOpen((o) => !o)} className="flex min-h-[36px] w-full items-center gap-2 px-3 py-1.5 text-left text-fg-muted hover:text-fg">409 <Wrench className={cn("size-3.5", pending && "animate-pulse-soft text-accent")} />410 <span className="font-medium font-mono">{call.name}</span>411 {pending ? <span className="text-fg-subtle">running…</span> : call.isError ? <Badge variant="danger">error</Badge> : <Badge variant="success">done</Badge>}412 {call.durationMs !== undefined ? <span className="text-fg-subtle">· {formatMs(call.durationMs)}</span> : null}413 <span className="ml-auto">{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}</span>414 </button>415 {open ? (416 <div className="grid gap-2 border-t border-border p-3 sm:grid-cols-2">417 <div>418 <div className="mb-1 text-[11px] uppercase tracking-wide text-fg-subtle">Arguments</div>419 <pre className="overflow-x-auto rounded-md bg-bg-muted p-2 font-mono text-[11.5px] scrollbar-thin">{pretty(call.args)}</pre>420 </div>421 <div>422 <div className="mb-1 text-[11px] uppercase tracking-wide text-fg-subtle">Result</div>423 <pre className="overflow-x-auto rounded-md bg-bg-muted p-2 font-mono text-[11.5px] scrollbar-thin">{pending ? "…" : pretty(JSON.stringify(call.result))}</pre>424 </div>425 </div>426 ) : null}427 </div>428 );429}430431function pretty(s: string): string {432 try {433 return JSON.stringify(JSON.parse(s), null, 2);434 } catch {435 return s;436 }437}438439function Citations({ items }: { items: { url?: string; title?: string; snippet?: string }[] }) {440 const unique = React.useMemo(() => {441 const seen = new Set<string>();442 return items.filter((c) => {443 const k = c.url ?? c.title ?? "";444 if (!k || seen.has(k)) return false;445 seen.add(k);446 return true;447 });448 }, [items]);449 if (!unique.length) return null;450 return (451 <div className="flex flex-wrap gap-1.5 pt-1">452 {unique.slice(0, 12).map((c, i) => (453 <a key={i} href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-[260px] items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11.5px] text-fg-muted hover:border-border-strong hover:text-fg" title={c.snippet}>454 <ExternalLink className="size-3 shrink-0" />455 <span className="truncate">{c.title || safeHost(c.url)}</span>456 </a>457 ))}458 </div>459 );460}461462function safeHost(url?: string): string {463 try {464 return url ? new URL(url).hostname : "source";465 } catch {466 return "source";467 }468}469470function dedupeServerTools(list: { name: string; status: string }[]) {471 const map = new Map<string, { name: string; status: string }>();472 for (const t of list) map.set(t.name, t);473 return [...map.values()];474}475476export function AttachmentChip({ a, onRemove }: { a: { attachmentId?: string; id?: string; kind: string; name: string; mimeType: string; sizeBytes: number }; onRemove?: () => void }) {477 const id = a.attachmentId ?? a.id;478 if (a.kind === "image" && id) {479 return (480 <div className="relative">481 {/* eslint-disable-next-line @next/next/no-img-element */}482 <img src={`/api/attachments?id=${id}`} alt={a.name} className="max-h-40 max-w-[240px] rounded-lg border border-border object-cover" />483 {onRemove ? (484 <button onClick={onRemove} className="tap absolute -right-1.5 -top-1.5 rounded-full border border-border bg-bg-elevated p-0.5 text-fg-muted hover:text-danger" aria-label="Remove attachment">485 <X className="size-3" />486 </button>487 ) : null}488 </div>489 );490 }491 return (492 <div className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-2.5 py-1.5 text-[12px]">493 <FileText className="size-3.5 text-fg-subtle" />494 <span className="max-w-[180px] truncate">{a.name}</span>495 <span className="text-fg-subtle">{(a.sizeBytes / 1024).toFixed(0)} KB</span>496 {onRemove ? (497 <button onClick={onRemove} className="tap ml-0.5 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Remove attachment">498 <X className="size-3" />499 </button>500 ) : null}501 </div>502 );503}504