SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
26.5 KB · 504 lines tsx
Raw Blame History
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 &amp; 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