feat: v0.3.0 re-release - add dashboard & sessions, hide pi option

- Usage dashboard: KPIs, model split, daily bars, year heatmap, recent requests
- Sessions management: list/preview/archive/delete with streaming preview
- Double-click daily bar opens per-model breakdown modal
- Paginated recent requests (30/page)
- Hide pi option from nav (kept code, just removed from UI)
- Timezone fixes: today/day_key/heatmap now use local calendar day
- Streaming preview with 20MB byte cap + 500-char frontend clip
- README attribution to kimicode-dashboard (MIT, JochenYang)
This commit is contained in:
KimiSwitch Dev committed 2026-07-28 09:16:21 +08:00
1 parent 139af18826
commit 32de98bff5
19 files changed
+3648 -35

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { DailyDetailModal } from "./DailyDetailModal";
interface DailyBarsProps {
daily: DailyRow[];
/** Ordered model names for the colour palette & legend */
modelNames: string[];
}
/** Deterministic colour palette — one hue per model index */
const PALETTE = [
"#3b82f6", // blue
"#22c55e", // green
"#f97316", // orange
"#a855f7", // purple
"#ec4899", // pink
"#eab308", // yellow
"#06b6d4", // cyan
"#f43f5e", // rose
"#84cc16", // lime
"#8b5cf6", // violet
];
export function modelColor(index: number): string {
return PALETTE[index % PALETTE.length];
}
/** Short display name for a raw model id */
function shortModel(model: string): string {
const bare = model.includes("/") ? model.split("/").pop()! : model;
return bare;
}
export function DailyBars({ daily, modelNames }: DailyBarsProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<DailyRow | null>(null);
const colorMap = useMemo(() => {
const map: Record<string, string> = {};
modelNames.forEach((m, i) => {
map[m] = modelColor(i);
});
return map;
}, [modelNames]);
if (!daily?.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
{t("noData")}
</div>
);
}
const max = Math.max(...daily.map((d) => d.totalTokens || 0), 1);
return (
<div className="space-y-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{modelNames.map((m) => (
<span key={m} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorMap[m] }}
/>
{shortModel(m)}
</span>
))}
</div>
{/* Bars */}
<div className="flex h-52 items-end gap-1 overflow-x-auto px-0.5 pb-1">
{daily.map((d) => {
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
const total = d.totalTokens || 1;
// Build stacked segments sorted by token count (largest at bottom)
const segments = Object.entries(d.byModel || {})
.map(([model, tokens]) => ({
model,
tokens,
color: colorMap[model] || "#6b7280",
height: Math.max(1, Math.round((tokens / total) * h)),
}))
.sort((a, b) => b.tokens - a.tokens);
const tooltipLines = [
d.date,
`总 Token: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · hit ${fmtPct(d.cacheHitRate)}`,
...segments.map(
(s) => `${shortModel(s.model)}: ${fmtTokens(s.tokens)}`
),
"",
t("doubleClickHint"),
].join("\n");
return (
<button
key={d.date}
type="button"
title={tooltipLines}
onDoubleClick={() => setSelected(d)}
className="group flex min-w-[18px] flex-1 flex-col items-center gap-1.5 border-0 bg-transparent p-0 cursor-help"
>
<div
className="w-full flex flex-col-reverse rounded-t-md rounded-b-sm overflow-hidden transition-transform group-hover:scale-105 group-active:scale-95"
style={{ height: h }}
>
{segments.map((s) => (
<div
key={s.model}
className="w-full transition-opacity group-hover:opacity-80"
style={{
height: s.height,
backgroundColor: s.color,
}}
/>
))}
</div>
<span className="whitespace-nowrap text-[10px] text-gray-500">
{d.date.slice(5)}
</span>
</button>
);
})}
</div>
{selected &&
createPortal(
<DailyDetailModal
day={selected}
colorMap={colorMap}
onClose={() => setSelected(null)}
/>,
document.body
)}
</div>
);
}
@@ -0,0 +1,202 @@
import { useEffect } from "react";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { modelColor } from "./DailyBars";
interface DailyDetailModalProps {
day: DailyRow;
/** model name → colour hex */
colorMap: Record<string, string>;
onClose: () => void;
}
function shortModel(model: string): string {
const bare = model.includes("/") ? model.split("/").pop()! : model;
return bare;
}
export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalProps) {
const { t } = useTranslation();
// ESC to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Lock background scroll while modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
const total = day.totalTokens || 1;
const segments = Object.entries(day.byModel || {})
.map(([model, tokens]) => ({
model,
tokens,
pct: (tokens / total) * 100,
color: colorMap[model] || "#6b7280",
}))
.sort((a, b) => b.tokens - a.tokens);
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={day.date}
>
<div
className="w-full max-w-lg rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between border-b border-[#2a2a2e] px-5 py-3.5">
<div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">
{t("dayDetail")}
</div>
<div className="mt-0.5 text-lg font-semibold text-[#e5e5e7]">
{day.date}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="rounded-md p-1 text-gray-500 hover:bg-[#2a2a2e] hover:text-gray-200 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Day summary chips */}
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
<SummaryChip
label={t("totalTokens")}
value={fmtTokens(day.totalTokens)}
tone="default"
/>
<SummaryChip
label={t("cost")}
value={fmtUsd(day.costUsd)}
tone="orange"
/>
<SummaryChip
label={t("requests")}
value={Math.round(day.requests).toLocaleString()}
tone="blue"
/>
<SummaryChip
label={t("cacheHitRate")}
value={fmtPct(day.cacheHitRate)}
tone="green"
/>
</div>
{/* Per-model breakdown */}
<div className="px-5 pb-4">
<div className="mb-2 text-[11px] uppercase tracking-wider text-gray-500">
{t("modelDistribution")} · {segments.length}
</div>
{segments.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500">
{t("noData")}
</div>
) : (
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
{segments.map((s, i) => (
<div key={s.model} className="flex items-center gap-3 text-sm">
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: modelColor(i) }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className="truncate text-[#e5e5e7]"
title={s.model}
>
{shortModel(s.model)}
</span>
<span className="shrink-0 text-xs text-gray-400 tabular-nums">
{fmtTokens(s.tokens)}{" "}
<span className="text-gray-500">
· {s.pct.toFixed(1)}%
</span>
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#2a2a2e]">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(2, s.pct)}%`,
backgroundColor: modelColor(i),
}}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer hint */}
<div className="border-t border-[#2a2a2e] px-5 py-2 text-[11px] text-gray-500">
{t("modalEscHint")}
</div>
</div>
</div>
);
}
function SummaryChip({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "default" | "orange" | "blue" | "green";
}) {
const valueClass =
tone === "orange"
? "text-orange-400"
: tone === "blue"
? "text-blue-400"
: tone === "green"
? "text-emerald-400"
: "text-[#e5e5e7]";
return (
<div className="rounded-md bg-[#1c1c20] px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-gray-500">
{label}
</div>
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
{value}
</div>
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
import { useState, type ReactNode } from "react";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { Heatmap } from "./Heatmap";
const RANGES: { value: DashboardRange; label: string }[] = [
{ value: "today", label: "今天" },
{ value: "7d", label: "7 天" },
{ value: "30d", label: "30 天" },
{ value: "all", label: "全部" },
];
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="border-b border-[#2a2a2e] px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-[#e5e5e7]">{title}</h3>
{subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
</div>
<div className="p-4">{children}</div>
</div>
);
}
function KpiCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a] p-3">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
{value}
</div>
{sub && <div className="mt-0.5 text-[11px] text-gray-500">{sub}</div>}
</div>
);
}
export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
if (loading && !data) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-red-400 mb-2">加载失败</div>
<div className="text-sm text-gray-500">{error}</div>
<button
onClick={() => refresh()}
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
>
重试
</button>
</div>
</div>
);
}
if (!data) return null;
const totals = data.stats.totals;
const daily = data.stats.daily;
const models = data.stats.models;
const recent = data.stats.recent;
const heatmap = data.heatmap;
const rangeTotals = data.rangeTotals;
const visibleModels = showAllModels ? models : models.slice(0, 8);
// Recent pagination
const RECENT_PAGE_SIZE = 30;
const recentTotal = data.stats.recentTotal;
const recentTotalPages = Math.max(1, Math.ceil(recent.length / RECENT_PAGE_SIZE));
const recentCurrentPage = Math.min(recentPage, recentTotalPages);
const recentStart = (recentCurrentPage - 1) * RECENT_PAGE_SIZE;
const recentPageRows = recent.slice(recentStart, recentStart + RECENT_PAGE_SIZE);
// Ordered model names for chart colour mapping
const modelNames = models.map((m) => m.model);
const kpiItems = [
{ label: "请求数", value: fmtInt(totals.requests) },
{ label: "非缓存输入", value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
{ label: "输出", value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
{ label: "缓存读", value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
{ label: "缓存创建", value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
{ label: "缓存命中", value: fmtPct(totals.cacheHitRate) },
{ label: "总 Token", value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
{ label: "预估费用", value: fmtUsd(totals.costUsd) },
];
return (
<div className="h-full overflow-auto p-4 space-y-4">
{/* Range tabs + refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
{RANGES.map((r) => (
<button
key={r.value}
type="button"
onClick={() => changeRange(r.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r.value
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{r.label}
</button>
))}
</div>
<button
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] text-gray-400 hover:text-[#e5e5e7] disabled:opacity-50"
>
{loading ? "刷新中…" : "↻ 刷新"}
</button>
</div>
{/* Range overview cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{RANGES.map((r) => {
const tot = rangeTotals[r.value];
if (!tot) return null;
const active = range === r.value;
return (
<button
key={r.value}
type="button"
onClick={() => changeRange(r.value)}
className={`rounded-xl border p-4 text-left transition-colors ${
active
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
: "border-[#2a2a2e] bg-[#16161a] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
}`}
>
<div className="text-xs text-gray-500">{r.label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
{fmtTokens(tot.totalTokens)}
</div>
<div className="mt-1 text-[11px] text-gray-500">
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
);
})}
</div>
{/* KPI strip */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-8">
{kpiItems.map((item) => (
<KpiCard key={item.label} label={item.label} value={item.value} sub={item.sub} />
))}
</div>
{/* Heatmap — full-year strip, fixed-size cells, centered */}
<Card title="全年热力图" subtitle="按 Token 量着色">
<Heatmap heatmap={heatmap} />
</Card>
{/* Charts + Model side table */}
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<Card
title="每日用量趋势"
subtitle={
daily.length > 30
? `近 30 天 · 共 ${daily.length} 天`
: `按模型着色 · ${daily.length} 天`
}
>
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
</Card>
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
{models.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
) : (
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<th className="pb-2 pr-3 font-normal">模型</th>
<th className="pb-2 pr-3 font-normal text-right">请求</th>
<th className="pb-2 pr-3 font-normal text-right">Token</th>
<th className="pb-2 pr-3 font-normal text-right">缓存命中</th>
<th className="pb-2 font-normal text-right">费用</th>
</tr>
</thead>
<tbody>
{visibleModels.map((m) => (
<tr
key={m.model}
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
>
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{
backgroundColor:
models.indexOf(m) < 10
? ["#3b82f6","#22c55e","#f97316","#a855f7","#ec4899","#eab308","#06b6d4","#f43f5e","#84cc16","#8b5cf6"][models.indexOf(m)]
: "#6b7280",
}}
/>
<div className="min-w-0">
<div className="text-[#e5e5e7] truncate">{m.modelDisplay || m.model}</div>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600">估算</span>
)}
</div>
</div>
</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-400">{fmtUsd(m.costUsd)}</td>
</tr>
))}
</tbody>
</table>
{models.length > 8 && (
<button
type="button"
onClick={() => setShowAllModels(!showAllModels)}
className="mt-3 text-sm text-blue-500 hover:text-blue-400"
>
{showAllModels ? "收起" : `展开全部 (${models.length})`}
</button>
)}
</div>
)}
</Card>
</div>
{/* Recent requests */}
<Card
title="最近请求"
subtitle={
recentTotal > recent.length
? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条`
: `${recent.length} 条`
}
>
{recent.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
) : (
<>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<th className="pb-2 pr-4 font-normal">时间</th>
<th className="pb-2 pr-4 font-normal">模型</th>
<th className="pb-2 pr-4 font-normal text-right">输入</th>
<th className="pb-2 pr-4 font-normal text-right">输出</th>
<th className="pb-2 pr-4 font-normal text-right">缓存读</th>
<th className="pb-2 font-normal text-right">费用</th>
</tr>
</thead>
<tbody>
{recentPageRows.map((r, i) => (
<tr key={recentStart + i} className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]">
<td className="py-1.5 pr-4 text-gray-500 whitespace-nowrap">
{fmtTime(r.time)}
</td>
<td className="py-1.5 pr-4 text-[#e5e5e7]">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-400">{fmtUsd(r.costUsd)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination footer */}
<div className="mt-2 flex items-center justify-between gap-2 border-t border-[#2a2a2e] pt-2 text-xs text-gray-500">
<span>
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
每页 {RECENT_PAGE_SIZE} 条
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage(1)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="首页"
>
«
</button>
<button
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="上一页"
>
‹
</button>
<button
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="下一页"
>
›
</button>
<button
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage(recentTotalPages)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="末页"
>
»
</button>
</div>
</div>
</>
)}
</Card>
{/* Attribution footer */}
<div className="text-xs text-gray-500 pb-2">
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
{data.meta.home}
<br />
仪表盘功能基于{" "}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400 underline"
>
kimicode-dashboard
</a>{" "}
(MIT,© JochenYang)移植
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
interface HeatmapProps {
heatmap: HeatmapData;
}
const LEVEL_BG = [
"bg-[#1a1a1e]", // 0 — empty / no usage
"bg-emerald-900/55",
"bg-emerald-700/65",
"bg-emerald-500/85",
"bg-emerald-400",
];
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const CELL = 12; // px
const GAP = 3; // px
const LABEL_W = 28; // dow label column width
interface HoverState {
cell: HeatmapCell;
x: number; // viewport coords
y: number;
}
export function Heatmap({ heatmap }: HeatmapProps) {
const { t, lang } = useTranslation();
const cells = heatmap?.cells || [];
const monthLabels = heatmap?.monthLabels || [];
const dowLabels = lang === "zh" ? DOW_LABELS_ZH : DOW_LABELS_EN;
const weekCount = useMemo(() => {
if (!cells.length) return 0;
return Math.max(...cells.map((c) => c.weekIndex)) + 1;
}, [cells]);
const grid = useMemo(() => {
const cols: (HeatmapCell | null)[][] = Array.from(
{ length: weekCount },
() => Array(7).fill(null)
);
for (const c of cells) {
if (c.weekIndex >= 0 && c.weekIndex < weekCount) {
cols[c.weekIndex][c.dow] = c;
}
}
return cols;
}, [cells, weekCount]);
// Tooltip state with 120ms enter delay, matching reference (delayDuration=120)
const [hover, setHover] = useState<HoverState | null>(null);
const enterTimer = useRef<number | null>(null);
const leaveTimer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (enterTimer.current) window.clearTimeout(enterTimer.current);
if (leaveTimer.current) window.clearTimeout(leaveTimer.current);
};
}, []);
const showTooltip = (cell: HeatmapCell, e?: MouseEvent) => {
if (leaveTimer.current) {
window.clearTimeout(leaveTimer.current);
leaveTimer.current = null;
}
enterTimer.current = window.setTimeout(() => {
if (e) {
setHover({ cell, x: e.clientX, y: e.clientY });
} else {
// Keyboard focus path — fall back to last known position
setHover((prev) => (prev ? { ...prev, cell } : { cell, x: 0, y: 0 }));
}
}, 120);
};
const moveTooltip = (cell: HeatmapCell, e: MouseEvent) => {
if (hover && hover.cell.date === cell.date) {
setHover({ cell, x: e.clientX, y: e.clientY });
}
};
const showTooltipFromFocus = (cell: HeatmapCell) => showTooltip(cell);
const hideTooltip = () => {
if (enterTimer.current) {
window.clearTimeout(enterTimer.current);
enterTimer.current = null;
}
leaveTimer.current = window.setTimeout(() => {
setHover(null);
}, 60);
};
if (!cells.length) {
return (
<div className="flex h-28 items-center justify-center text-sm text-gray-500">
{t("noData")}
</div>
);
}
return (
<div className="space-y-2">
{/* Fixed-size year strip, centered; scrolls horizontally only when the card is narrower than the strip */}
<div className="overflow-x-auto pb-1">
<div
className="mx-auto w-fit"
style={{ minWidth: LABEL_W + weekCount * (CELL + GAP) }}
>
{/* month labels */}
<div
className="mb-1 grid"
style={{
gridTemplateColumns: `${LABEL_W}px repeat(${weekCount}, ${CELL}px)`,
columnGap: GAP,
}}
>
<div />
{Array.from({ length: weekCount }, (_, wi) => {
const label = monthLabels.find((m) => m.weekIndex === wi);
return (
<div
key={wi}
className="text-[10px] leading-none text-gray-500 whitespace-nowrap overflow-visible"
>
{label ? label.label : ""}
</div>
);
})}
</div>
<div className="flex" style={{ gap: GAP }}>
{/* dow labels */}
<div
className="flex flex-col pr-0"
style={{ width: LABEL_W, gap: GAP }}
>
{dowLabels.map((d, i) => (
<div
key={d}
className={`flex items-center text-[10px] text-gray-500 ${
i % 2 === 1 ? "opacity-100" : "opacity-0"
}`}
style={{ height: CELL }}
>
{d}
</div>
))}
</div>
{/* cells */}
<div className="flex" style={{ gap: GAP }}>
{grid.map((week, wi) => (
<div key={wi} className="flex flex-col" style={{ gap: GAP }}>
{week.map((cell, di) => {
if (!cell) {
return (
<div
key={`${wi}-${di}`}
className="rounded-[3px] bg-transparent"
style={{ width: CELL, height: CELL }}
/>
);
}
const level = cell.level ?? 0;
return (
<button
key={cell.date}
type="button"
aria-label={cell.date}
onMouseEnter={(e) => showTooltip(cell, e)}
onMouseMove={(e) => moveTooltip(cell, e)}
onMouseLeave={hideTooltip}
onFocus={() => showTooltipFromFocus(cell)}
onBlur={hideTooltip}
className={`rounded-[3px] ring-1 ring-inset ring-white/5 transition-transform hover:scale-[1.35] focus:scale-[1.35] outline-none ${LEVEL_BG[level] || LEVEL_BG[0]}`}
style={{ width: CELL, height: CELL }}
/>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 text-[11px] text-gray-500">
<span>
{heatmap.start} → {heatmap.end}
</span>
<div className="flex items-center gap-1.5">
<span>{t("heatLess")}</span>
{[0, 1, 2, 3, 4].map((lv) => (
<div
key={lv}
className={`h-3 w-3 rounded-[3px] ring-1 ring-inset ring-white/5 ${LEVEL_BG[lv]}`}
/>
))}
<span>{t("heatMore")}</span>
</div>
</div>
{hover &&
createPortal(
<HeatmapTooltip state={hover} />,
document.body
)}
</div>
);
}
function HeatmapTooltip({ state }: { state: HoverState }) {
const { t } = useTranslation();
const { cell, x, y } = state;
// Position above the cursor with a small offset; flip below if too close to top.
const offset = 14;
const style: React.CSSProperties = {
position: "fixed",
top: y - offset,
left: x,
transform: "translate(-50%, -100%)",
zIndex: 9999,
pointerEvents: "none",
};
return (
<div
style={style}
className="rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-xl"
role="tooltip"
>
<div className="font-medium text-gray-50">{cell.date}</div>
<div className="text-gray-400">
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
{fmtInt(cell.requests)} {t("requests")}
</div>
<div className="text-gray-400">
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
</div>
</div>
);
}
// Local int formatter (same style as DailyBars in this project)
function fmtInt(n: number): string {
return Math.round(n).toLocaleString();
}
+763
View File
@@ -0,0 +1,763 @@
import { useMemo, useState } from "react";
import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n";
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
function fmtBytes(n: number): string {
const v = Number(n) || 0;
if (v < 1024) return `${v} B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`;
return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function parseTime(value: string | number | null | undefined): number {
if (value == null || value === "") return 0;
if (typeof value === "number") return value > 1e12 ? value : value * 1000;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function rowKey(row: SessionRow): string {
return `${row.workspaceId}/${row.id}`;
}
type ConfirmState =
| { mode: "one"; row: SessionRow }
| { mode: "bulk"; rows: SessionRow[] }
| { mode: "workspace"; workspace: WorkspaceRow }
| null;
type PreviewState = {
loading: boolean;
error: string | null;
messages: { role: string; time: number | null; text: string }[];
truncated: boolean;
row: SessionRow;
} | null;
export function SessionsPage() {
const { t, lang } = useTranslation();
const locale = lang === "zh" ? "zh" : "en";
const {
status,
setStatus,
workspace,
setWorkspace,
data,
loading,
error,
setError,
refresh,
archiveSession,
unarchiveSession,
deleteSession,
deleteWorkspace,
getPreview,
} = useSessions();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [preview, setPreview] = useState<PreviewState>(null);
const workspaces = data?.workspaces ?? [];
const sessions = data?.sessions ?? [];
const workspaceMap = useMemo(() => {
const m = new Map<string, WorkspaceRow>();
for (const w of workspaces) m.set(w.id, w);
return m;
}, [workspaces]);
const allKeys = useMemo(() => sessions.map(rowKey), [sessions]);
const allSelected = allKeys.length > 0 && allKeys.every((k) => selected.has(k));
const someSelected = allKeys.some((k) => selected.has(k)) && !allSelected;
const toggleSelect = (row: SessionRow) => {
const key = rowKey(row);
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleSelectAll = () => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(allKeys));
};
const onArchive = async (row: SessionRow) => {
const key = rowKey(row);
setBusyId(key);
try {
await archiveSession(row.workspaceId, row.id);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
};
const onUnarchive = async (row: SessionRow) => {
const key = rowKey(row);
setBusyId(key);
try {
await unarchiveSession(row.workspaceId, row.id);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
};
const openPreview = async (row: SessionRow) => {
setPreview({
loading: true,
error: null,
messages: [],
truncated: false,
row,
});
try {
const res = await getPreview(row.workspaceId, row.id, row.status);
setPreview({
loading: false,
error: null,
messages: res.messages,
truncated: res.truncated,
row,
});
} catch (e) {
setPreview({
loading: false,
error: e instanceof Error ? e.message : String(e),
messages: [],
truncated: false,
row,
});
}
};
const bulkArchive = async () => {
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "active");
for (const row of rows) {
try {
await archiveSession(row.workspaceId, row.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
break;
}
}
setSelected(new Set());
};
const bulkUnarchive = async () => {
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "archived");
for (const row of rows) {
try {
await unarchiveSession(row.workspaceId, row.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
break;
}
}
setSelected(new Set());
};
const runConfirm = async () => {
if (!confirm) return;
setConfirmBusy(true);
try {
if (confirm.mode === "one") {
await deleteSession(confirm.row.workspaceId, confirm.row.id, confirm.row.status);
} else if (confirm.mode === "bulk") {
for (const row of confirm.rows) {
await deleteSession(row.workspaceId, row.id, row.status);
}
} else if (confirm.mode === "workspace") {
await deleteWorkspace(confirm.workspace.id);
if (workspace === confirm.workspace.id) setWorkspace("all");
}
setConfirm(null);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setConfirmBusy(false);
}
};
const totalSessions = workspaces.reduce(
(a, w) => a + (w.activeCount || 0) + (w.archivedCount || 0),
0
);
return (
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-[#e5e5e7]">{t("sessionsTitle")}</h2>
<p className="mt-1 max-w-[60ch] text-sm text-gray-500">{t("sessionsSubtitle")}</p>
</div>
<button
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
>
{loading ? t("refreshing") : t("refresh")}
</button>
</div>
{error && (
<div className="shrink-0 rounded-lg border border-red-500/40 bg-red-900/20 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_1fr]">
{/* Workspace rail */}
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
<div className="text-sm font-medium text-[#e5e5e7]">{t("workspaces")}</div>
<div className="text-xs text-gray-500 mt-0.5">{t("workspaceIsolatedHint")}</div>
</div>
<div className="flex min-h-0 flex-1 flex-col p-2 overflow-hidden">
<button
type="button"
onClick={() => setWorkspace("all")}
className={`flex w-full shrink-0 items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
workspace === "all"
? "bg-blue-600/20 text-[#e5e5e7]"
: "text-gray-400 hover:bg-[#1f1f23] hover:text-[#e5e5e7]"
}`}
>
<span>{t("allWorkspaces")}</span>
<span className="text-xs tabular-nums">{fmtInt(totalSessions, locale)}</span>
</button>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto mt-1">
{workspaces.map((w) => {
const total = (w.activeCount || 0) + (w.archivedCount || 0);
const isEmpty = total === 0 || w.empty;
return (
<div
key={w.id}
className={`group flex items-start gap-1 rounded-md ${
workspace === w.id ? "bg-blue-600/20" : "hover:bg-[#1f1f23]"
}`}
>
<button
type="button"
onClick={() => setWorkspace(w.id)}
className={`flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2 text-left ${
workspace === w.id ? "text-[#e5e5e7]" : "text-gray-400 group-hover:text-[#e5e5e7]"
}`}
title={w.root || w.id}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{w.name || w.id}</span>
<span className="shrink-0 text-[11px] tabular-nums">
{isEmpty ? (
<span className="rounded border border-[#2a2a2e] px-1.5 py-0 text-[10px] text-gray-500">
{t("emptyWorkspace")}
</span>
) : (
<>
{w.activeCount || 0}
{w.archivedCount ? `/${w.archivedCount}` : ""}
</>
)}
</span>
</div>
{w.root && (
<span className="truncate font-mono text-[10px] opacity-70">{w.root}</span>
)}
</button>
{isEmpty && (
<button
type="button"
className="mt-1 mr-1 h-7 w-7 shrink-0 rounded text-red-400/80 opacity-70 hover:bg-red-900/30 hover:text-red-300 group-hover:opacity-100"
title={t("deleteWorkspace")}
onClick={(e) => {
e.stopPropagation();
setConfirm({ mode: "workspace", workspace: w });
}}
>
×
</button>
)}
</div>
);
})}
{!workspaces.length && !loading && (
<div className="px-3 py-6 text-center text-xs text-gray-500">{t("noSessions")}</div>
)}
</div>
</div>
</div>
{/* Sessions table */}
<div className="flex min-h-0 flex-col space-y-3 overflow-hidden">
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
{(
[
["active", t("statusActive")],
["archived", t("statusArchived")],
["all", t("statusAll")],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => {
setStatus(value);
setSelected(new Set());
}}
className={`px-3 py-1 text-sm rounded transition-colors ${
status === value
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{sessions.length > 0 && (
<button
type="button"
onClick={toggleSelectAll}
className="px-2 py-1 text-xs text-gray-400 hover:text-[#e5e5e7]"
>
{allSelected ? t("deselectAll") : t("selectAll")}
</button>
)}
{selected.size > 0 && (
<>
<span className="text-xs text-gray-500">
{t("selectedCount", { n: String(selected.size) })}
</span>
{status !== "archived" && (
<button
type="button"
onClick={bulkArchive}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
>
{t("archive")}
</button>
)}
{status !== "active" && (
<button
type="button"
onClick={bulkUnarchive}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
>
{t("unarchive")}
</button>
)}
<button
type="button"
onClick={() => {
const rows = sessions.filter((s) => selected.has(rowKey(s)));
if (rows.length) setConfirm({ mode: "bulk", rows });
}}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20"
>
{t("delete")}
</button>
</>
)}
<select
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
>
<option value="all">{t("allWorkspaces")}</option>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name || w.id}
</option>
))}
</select>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
<div className="text-sm font-medium text-[#e5e5e7]">
{workspace === "all"
? t("allWorkspaces")
: workspaceMap.get(workspace)?.name || workspace}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{t("sessionsInView", { n: String(sessions.length) })}
{workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[#16161a] text-left text-gray-500 border-b border-[#2a2a2e]">
<tr>
<th className="w-10 px-3 py-2 font-normal">
<input
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
type="checkbox"
className="h-4 w-4 accent-blue-600"
checked={allSelected}
disabled={!sessions.length}
onChange={toggleSelectAll}
aria-label={t("selectAll")}
/>
</th>
<th className="px-2 py-2 font-normal">{t("sessionTitle")}</th>
<th className="px-2 py-2 font-normal">{t("workspace")}</th>
<th className="px-2 py-2 font-normal">{t("status")}</th>
<th className="px-2 py-2 font-normal text-right">{t("size")}</th>
<th className="px-2 py-2 font-normal">{t("updated")}</th>
<th className="px-2 py-2 font-normal text-right">{t("actions")}</th>
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr>
<td colSpan={7} className="py-10 text-center text-gray-500">
{loading ? t("scanning") : t("noSessions")}
</td>
</tr>
) : (
sessions.map((row) => {
const key = rowKey(row);
const ws = workspaceMap.get(row.workspaceId);
const busy = busyId === key;
return (
<tr
key={key}
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
>
<td className="px-3 py-2">
<input
type="checkbox"
className="h-4 w-4 accent-blue-600"
checked={selected.has(key)}
onChange={() => toggleSelect(row)}
/>
</td>
<td className="px-2 py-2 max-w-[280px]">
<div className="flex min-w-0 flex-col gap-0.5">
<button
type="button"
className="truncate text-left font-medium text-[#e5e5e7] hover:text-blue-400 hover:underline"
title={row.title || t("openPreview")}
onClick={() => openPreview(row)}
>
{row.title || row.id}
</button>
<span className="truncate font-mono text-[10px] text-gray-500" title={row.id}>
{row.id}
</span>
{row.workDir && (
<span
className="truncate font-mono text-[10px] text-gray-500/80"
title={row.workDir}
>
{row.workDir}
</span>
)}
</div>
</td>
<td className="px-2 py-2 max-w-[160px]">
<span className="truncate text-sm text-gray-400">
{ws?.name || row.workspaceId}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap">
{row.status === "archived" ? (
<span className="rounded-md bg-amber-900/30 text-amber-300 px-2 py-0.5 text-xs">
{t("statusArchived")}
</span>
) : (
<span className="rounded-md bg-emerald-900/30 text-emerald-300 px-2 py-0.5 text-xs">
{t("statusActive")}
</span>
)}
</td>
<td className="px-2 py-2 text-right tabular-nums text-gray-400">
{fmtBytes(row.bytes)}
</td>
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-gray-400">
{fmtTime(parseTime(row.updatedAt), locale)}
</td>
<td className="px-2 py-2 text-right">
<div className="flex justify-end gap-1">
{row.status === "active" ? (
<button
type="button"
disabled={busy}
onClick={() => onArchive(row)}
title={t("archive")}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
>
{t("archive")}
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => onUnarchive(row)}
title={t("unarchive")}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
>
{t("unarchive")}
</button>
)}
<button
type="button"
disabled={busy}
onClick={() => setConfirm({ mode: "one", row })}
title={t("delete")}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20 disabled:opacity-50"
>
{t("delete")}
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Attribution */}
<div className="text-xs text-gray-500 pb-2">
{data?.home && <>{data.home}</>}
<br />
会话管理功能基于{" "}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400 underline"
>
kimicode-dashboard
</a>{" "}
(MIT,© JochenYang)移植
</div>
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-[#e5e5e7]">
{confirm.mode === "bulk"
? t("confirmBulkTitle")
: confirm.mode === "workspace"
? t("confirmDeleteWorkspaceTitle")
: t("confirmDeleteTitle")}
</h3>
<p className="text-sm text-gray-400">
{confirm.mode === "bulk"
? t("confirmBulkDelete", { n: String(confirm.rows.length) })
: confirm.mode === "workspace"
? t("confirmDeleteWorkspace")
: t("confirmDeleteSession")}
</p>
{confirm.mode === "one" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.row.title || confirm.row.id}
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
{confirm.row.id}
</div>
</div>
)}
{confirm.mode === "bulk" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5 text-sm text-gray-400">
{t("selectedCount", { n: String(confirm.rows.length) })}
</div>
)}
{confirm.mode === "workspace" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.workspace.name || confirm.workspace.id}
</div>
{confirm.workspace.root && (
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
{confirm.workspace.root}
</div>
)}
<div className="mt-1 font-mono text-[11px] text-gray-500">
{confirm.workspace.id}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
<button
type="button"
disabled={confirmBusy}
onClick={() => setConfirm(null)}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
>
{t("cancel")}
</button>
<button
type="button"
disabled={confirmBusy}
onClick={runConfirm}
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-500 disabled:opacity-50"
>
{confirmBusy
? t("refreshing")
: confirm.mode === "workspace"
? t("deleteWorkspace")
: t("deleteForever")}
</button>
</div>
</div>
</div>
)}
{/* Preview dialog */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
<div className="shrink-0 space-y-2 border-b border-[#2a2a2e] p-5">
<h3 className="text-base font-semibold text-[#e5e5e7]">{t("previewTitle")}</h3>
<p className="text-sm text-gray-500">{t("previewHint")}</p>
<div className="text-xs text-gray-500">
<div className="truncate font-medium text-[#e5e5e7]">
{preview.row.title || preview.row.id}
</div>
<div className="mt-0.5 break-all font-mono opacity-80">{preview.row.id}</div>
</div>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
{preview.loading && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
{t("scanning")}
</div>
)}
{preview.error && (
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{preview.error}
</div>
)}
{!preview.loading && !preview.error && !preview.messages.length && (
<div className="py-10 text-center text-sm text-gray-500">{t("previewEmpty")}</div>
)}
{preview.messages.map((m, i) => {
const roleLabel =
m.role === "user"
? t("roleUser")
: m.role === "assistant"
? t("roleAssistant")
: m.role === "system"
? t("roleSystem")
: m.role;
return (
<PreviewMessageCard
key={`${m.role}-${i}`}
role={m.role}
roleLabel={roleLabel}
time={m.time}
text={m.text}
locale={locale}
/>
);
})}
{preview.truncated && (
<div className="text-center text-xs text-gray-500">{t("previewTruncated")}</div>
)}
</div>
<div className="shrink-0 flex justify-end border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
<button
type="button"
onClick={() => setPreview(null)}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529]"
>
{t("cancel")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Per-message card with 500-char collapse + expand toggle.
// Renders long texts lazily — prevents huge <pre> blocks from bloating the DOM.
const PREVIEW_TEXT_LIMIT = 500;
function PreviewMessageCard({
role,
roleLabel,
time,
text,
locale,
}: {
role: string;
roleLabel: string;
time: number | null;
text: string;
locale: string;
}) {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > PREVIEW_TEXT_LIMIT;
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
return (
<div
className={`rounded-lg border border-[#2a2a2e] px-3 py-2.5 ${
role === "user"
? "bg-blue-600/10"
: role === "assistant"
? "bg-[#1f1f23]"
: "bg-[#16161a]"
}`}
>
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span className="font-medium text-gray-300">{roleLabel}</span>
{time ? (
<span className="tabular-nums">{fmtTime(time, locale)}</span>
) : null}
</div>
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-gray-200">
{visible}
{!expanded && isLong && (
<span className="text-gray-500"> …</span>
)}
</pre>
{isLong && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
>
{expanded
? `收起 · ${text.length.toLocaleString()} 字符`
: `展开全文 · ${text.length.toLocaleString()} 字符`}
</button>
)}
</div>
);
}