SPB Git forge

spb/polyllm

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