TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { EyeOff, FileJson, FileText, History, MoreHorizontal, Play, RotateCcw, Share2, Trash2, Trophy } from "lucide-react";4import { useApp } from "@/components/app/store";5import { ProviderIcon } from "@/components/brand/provider-icon";6import { Button } from "@/components/ui/button";7import { Skeleton } from "@/components/ui/misc";8import { Tooltip } from "@/components/ui/tooltip";9import { ActionSheet } from "@/components/ui/sheet";10import { ConfirmDialog } from "@/components/common/confirm-dialog";11import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";12import { useIsMobile, useLongPress } from "@/lib/client/hooks";13import { cn, formatRelative, formatUsd, truncate } from "@/lib/utils";14import { sessionBlind, sessionCost, sessionWinner, type ArenaSessionDto } from "./types";1516interface Props {17 sessions: ArenaSessionDto[] | undefined;18 loading: boolean;19 activeId: string | null;20 disabled?: boolean;21 onOpen: (s: ArenaSessionDto) => void;22 onRerun: (s: ArenaSessionDto) => void;23 onDelete: (s: ArenaSessionDto) => Promise<void>;24 onExport: (s: ArenaSessionDto, format: "markdown" | "json") => void;25 onShare: (s: ArenaSessionDto) => void;26 className?: string;27}2829const PAGE = 8;3031/** Past comparisons: stacked rows on phones (tap = open, long-press/… = actions), table from `md` up. */32export function ArenaHistory({ sessions, loading, activeId, disabled, onOpen, onRerun, onDelete, onExport, onShare, className }: Props) {33 const { modelsByKey, preferences } = useApp();34 const isMobile = useIsMobile();35 const [showAll, setShowAll] = React.useState(false);36 const [menuFor, setMenuFor] = React.useState<ArenaSessionDto | null>(null);37 const [deleting, setDeleting] = React.useState<ArenaSessionDto | null>(null);38 const list = showAll ? sessions ?? [] : (sessions ?? []).slice(0, PAGE);3940 const nameOf = (k: string) => modelsByKey.get(k)?.displayName ?? k.split("/").slice(1).join("/");41 const providerOf = (k: string) => modelsByKey.get(k)?.provider ?? k.split("/")[0];4243 const rowData = (s: ArenaSessionDto) => {44 const winner = sessionWinner(s);45 return { winner, winnerName: winner ? nameOf(winner.modelKey) : null, winnerProvider: winner ? providerOf(winner.modelKey) : null, cost: sessionCost(s), blind: sessionBlind(s), active: s.id === activeId };46 };4748 const actions = (s: ArenaSessionDto) => [49 { key: "open", label: "Open (read-only)", icon: <Play />, onSelect: () => onOpen(s), disabled },50 { key: "rerun", label: "Run again", icon: <RotateCcw />, onSelect: () => onRerun(s), disabled },51 "separator" as const,52 { key: "md", label: "Export Markdown", icon: <FileText />, onSelect: () => onExport(s, "markdown") },53 { key: "json", label: "Export JSON", icon: <FileJson />, onSelect: () => onExport(s, "json") },54 { key: "share", label: "Share…", icon: <Share2 />, onSelect: () => onShare(s) },55 "separator" as const,56 { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(s) },57 ];5859 return (60 <section className={cn("min-w-0", className)} aria-label="Past sessions">61 <div className="mb-2 flex items-center gap-2">62 <History className="size-4 text-fg-subtle" />63 <h2 className="text-[13px] font-semibold tracking-tight">History</h2>64 {sessions?.length ? <span className="text-[12px] tabular-nums text-fg-subtle">{sessions.length}</span> : null}65 </div>6667 {loading && !sessions ? (68 <div className="space-y-2">69 {[0, 1, 2].map((i) => (70 <Skeleton key={i} className="h-[72px]" />71 ))}72 </div>73 ) : !sessions?.length ? (74 <p className="rounded-lg border border-dashed border-border px-3 py-4 text-[12.5px] text-fg-muted">Your past comparisons will show up here — prompt, models, winner, cost.</p>75 ) : isMobile ? (76 <ul className="overflow-hidden rounded-xl bg-bg-subtle">77 {list.map((s, i) => (78 <HistoryRow key={s.id} s={s} data={rowData(s)} first={i === 0} disabled={disabled} nameOf={nameOf} providerOf={providerOf} showCosts={preferences.showCosts} onOpen={() => onOpen(s)} onMore={() => setMenuFor(s)} />79 ))}80 </ul>81 ) : (82 <div className="overflow-hidden rounded-xl border border-border bg-bg-elevated">83 <table className="w-full text-[12.5px]">84 <thead>85 <tr className="text-left text-[10.5px] uppercase tracking-wide text-fg-subtle">86 <th className="px-3 py-2 font-medium">Prompt</th>87 <th className="px-3 py-2 font-medium">Models</th>88 <th className="px-3 py-2 font-medium">Winner</th>89 {preferences.showCosts ? <th className="px-3 py-2 text-right font-medium">Cost</th> : null}90 <th className="px-3 py-2 font-medium">Date</th>91 <th className="px-2 py-2" aria-label="Actions" />92 </tr>93 </thead>94 <tbody>95 {list.map((s) => {96 const d = rowData(s);97 return (98 <tr key={s.id} className={cn("group border-t border-hairline transition-colors hover:bg-bg-subtle/60", d.active && "bg-accent-soft/40")}>99 <td className="max-w-[420px] px-3 py-2">100 <button type="button" onClick={() => onOpen(s)} disabled={disabled} className="block w-full text-left disabled:opacity-60" aria-label={`Open session: ${truncate(s.prompt, 60)}`}>101 <span className="line-clamp-2 leading-5">{s.prompt}</span>102 </button>103 {d.blind ? (104 <span className="mt-0.5 inline-flex items-center gap-1 text-[10.5px] text-fg-subtle">105 <EyeOff className="size-3" /> blind106 </span>107 ) : null}108 </td>109 <td className="px-3 py-2">110 <span className="flex items-center -space-x-1">111 {s.modelKeys.map((k) => (112 <span key={k} className="flex size-6 items-center justify-center rounded-full border border-border bg-bg" title={nameOf(k)}>113 <ProviderIcon provider={providerOf(k)} size={12} />114 </span>115 ))}116 </span>117 </td>118 <td className="px-3 py-2">119 {d.winner ? (120 <span className="inline-flex max-w-[200px] items-center gap-1.5 rounded-md bg-accent-soft px-2 py-0.5 text-[11.5px] font-medium text-accent">121 <Trophy className="size-3 shrink-0" />122 <ProviderIcon provider={d.winnerProvider} size={11} />123 <span className="truncate">{d.winnerName}</span>124 </span>125 ) : (126 <span className="text-fg-subtle">—</span>127 )}128 </td>129 {preferences.showCosts ? <td className="px-3 py-2 text-right tabular-nums text-fg-muted">{d.cost > 0 ? `≈ ${formatUsd(d.cost, { precise: d.cost < 0.01 })}` : "—"}</td> : null}130 <td className="whitespace-nowrap px-3 py-2 text-fg-muted">131 <time dateTime={s.createdAt}>{formatRelative(s.createdAt)}</time>132 </td>133 <td className="px-2 py-2">134 <div className="flex items-center justify-end gap-0.5">135 <Tooltip content="Run again">136 <Button variant="ghost" size="icon-sm" className="hover-reveal" disabled={disabled} onClick={() => onRerun(s)} aria-label="Run again">137 <RotateCcw />138 </Button>139 </Tooltip>140 <DropdownMenu>141 <DropdownMenuTrigger asChild>142 <Button variant="ghost" size="icon-sm" aria-label="More actions">143 <MoreHorizontal />144 </Button>145 </DropdownMenuTrigger>146 <DropdownMenuContent align="end">147 {actions(s).map((a, i) =>148 a === "separator" ? (149 <DropdownMenuSeparator key={`sep-${i}`} />150 ) : (151 <DropdownMenuItem key={a.key} disabled={a.disabled} destructive={"destructive" in a && a.destructive} onSelect={a.onSelect}>152 {a.icon}153 {a.label}154 </DropdownMenuItem>155 ),156 )}157 </DropdownMenuContent>158 </DropdownMenu>159 </div>160 </td>161 </tr>162 );163 })}164 </tbody>165 </table>166 </div>167 )}168169 {(sessions?.length ?? 0) > PAGE ? (170 <Button variant="link" size="sm" className="mt-2 text-[12.5px]" onClick={() => setShowAll((v) => !v)}>171 {showAll ? "Show fewer" : `Show all ${sessions!.length}`}172 </Button>173 ) : null}174175 <ActionSheet open={Boolean(menuFor)} onOpenChange={(o) => !o && setMenuFor(null)} title={menuFor ? truncate(menuFor.prompt, 80) : undefined} items={menuFor ? actions(menuFor) : []} />176 <ConfirmDialog177 open={Boolean(deleting)}178 onOpenChange={(o) => !o && setDeleting(null)}179 title="Delete this comparison?"180 description="The prompt, every response, your votes and any public link are removed. Usage records are kept for your cost history."181 confirmLabel="Delete"182 destructive183 onConfirm={async () => {184 if (deleting) await onDelete(deleting);185 }}186 />187 </section>188 );189}190191interface RowData {192 winner: ReturnType<typeof sessionWinner>;193 winnerName: string | null;194 winnerProvider: string | null;195 cost: number;196 blind: boolean;197 active: boolean;198}199200function HistoryRow({ s, data: d, first, disabled, nameOf, providerOf, showCosts, onOpen, onMore }: { s: ArenaSessionDto; data: RowData; first: boolean; disabled?: boolean; nameOf: (k: string) => string; providerOf: (k: string) => string; showCosts: boolean; onOpen: () => void; onMore: () => void }) {201 const press = useLongPress({ onLongPress: onMore, onClick: () => !disabled && onOpen() });202 return (203 <li className={cn("relative flex items-stretch", !first && "border-t border-hairline", d.active && "bg-accent-soft/40")}>204 <div {...press} className="tap flex min-h-[64px] min-w-0 flex-1 select-none flex-col justify-center gap-1 px-3 py-2.5 text-left active:bg-bg-muted" role="button" tabIndex={0} aria-label={`Open session: ${truncate(s.prompt, 60)}`} onKeyDown={(e) => e.key === "Enter" && onOpen()}>205 <p className="line-clamp-2 text-[13.5px] leading-5">{s.prompt}</p>206 <div className="flex min-w-0 items-center gap-2 text-[11.5px] text-fg-subtle">207 <span className="flex items-center -space-x-1">208 {s.modelKeys.map((k) => (209 <span key={k} className="flex size-5 items-center justify-center rounded-full border border-border bg-bg" title={nameOf(k)}>210 <ProviderIcon provider={providerOf(k)} size={11} />211 </span>212 ))}213 </span>214 {d.blind ? <EyeOff className="size-3" aria-label="Blind" /> : null}215 <time dateTime={s.createdAt}>{formatRelative(s.createdAt)}</time>216 {showCosts && d.cost > 0 ? <span className="tabular-nums">≈ {formatUsd(d.cost, { precise: d.cost < 0.01 })}</span> : null}217 {d.winner ? (218 <span className="ml-auto inline-flex min-w-0 items-center gap-1 rounded bg-accent-soft px-1.5 py-0.5 font-medium text-accent">219 <Trophy className="size-3 shrink-0" />220 <span className="truncate">{d.winnerName}</span>221 </span>222 ) : null}223 </div>224 </div>225 <button type="button" onClick={onMore} className="flex w-11 shrink-0 items-center justify-center text-fg-subtle active:bg-bg-muted" aria-label="More actions">226 <MoreHorizontal className="size-4" />227 </button>228 </li>229 );230}231