feat(frontend): improve history detail and model list UX

Open model resources via row click, restyle history detail with
session/analysis panels and mock playback, and theme scrollbars
globally with navy light/dark tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-08-07 00:25:58 +08:00
parent 87b6392eea
commit 485ad623d4
4 changed files with 361 additions and 155 deletions

View File

@@ -180,7 +180,44 @@
@layer base {
* {
@apply border-border outline-ring/50;
/* Theme-aware scrollbars (light/dark navy tokens) */
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--muted-soft) 48%,
transparent
)
transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--muted-soft) 42%,
transparent
);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--muted-soft) 64%,
transparent
);
}
body {
@apply bg-background text-foreground;
letter-spacing: 0.01em;
@@ -261,42 +298,9 @@
}
}
/* Explicit opt-in alias; base layer already styles all scrollbars. */
.scrollbar-subtle {
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--muted-soft) 48%,
transparent
)
transparent;
}
.scrollbar-subtle::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-subtle::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-subtle::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--muted-soft) 42%,
transparent
);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--muted-soft) 64%,
transparent
);
}
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */

View File

@@ -98,7 +98,7 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
</div>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
<nav className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2">
<div className="space-y-1">
<NavButton
active={pathname === "/"}

View File

@@ -534,6 +534,7 @@ export function ComponentsModelsPage() {
loading={loading}
error={loadError || null}
onRetry={() => void load()}
onRowClick={openEdit}
empty={{
title:
resources.length === 0
@@ -635,14 +636,20 @@ export function ComponentsModelsPage() {
key: "actions",
header: "操作",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (resource) => (
<>
<div
className="flex justify-end gap-2"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => openEdit(resource)}
onClick={(event) => {
event.stopPropagation();
openEdit(resource);
}}
>
<Pencil size={14} />
@@ -654,6 +661,7 @@ export function ComponentsModelsPage() {
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${resource.name} 更多操作`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal size={15} />
</Button>
@@ -682,8 +690,9 @@ export function ComponentsModelsPage() {
className="rounded-lg"
disabled={deletingId === resource.id}
onSelect={(event) => {
// Defer confirm so row onRowClick does not fire afterward.
event.preventDefault();
void remove(resource);
window.setTimeout(() => void remove(resource), 0);
}}
>
{deletingId === resource.id ? (
@@ -695,7 +704,7 @@ export function ComponentsModelsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
</div>
),
},
]}
@@ -704,7 +713,7 @@ export function ComponentsModelsPage() {
</ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
<DialogContent className="scrollbar-subtle max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
<DialogDescription>
@@ -945,7 +954,7 @@ function FieldSection({
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
scrollable ? "scrollbar-subtle max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
].join(" ")}

View File

@@ -4,6 +4,7 @@ import {
Activity,
ArrowRight,
Camera,
ChartLine,
CheckCircle2,
ChevronDown,
ChevronUp,
@@ -15,6 +16,8 @@ import {
MessageSquareText,
MonitorSmartphone,
MoreHorizontal,
Pause,
Play,
Server,
Trash2,
Wrench,
@@ -23,6 +26,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
@@ -34,10 +38,8 @@ import {
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -61,6 +63,12 @@ const PAGE_SIZE = 5;
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
type ChannelFilter = (typeof channelFilters)[number];
type SortOrder = "newest" | "oldest";
type DetailTabId = "session" | "analysis";
const detailTabs = [
{ id: "session", label: "会话信息" },
{ id: "analysis", label: "分析" },
] as const;
function formatDate(value?: string | null): string {
if (!value) return "—";
@@ -364,6 +372,7 @@ export function HistoryPage() {
const [detail, setDetail] = useState<ConversationDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState("");
const [detailTab, setDetailTab] = useState<DetailTabId>("session");
const [deletingId, setDeletingId] = useState<string | null>(null);
const skipRowClickRef = useRef(false);
@@ -401,6 +410,7 @@ export function HistoryPage() {
const openDetail = useCallback(async (conversation: Conversation) => {
setDialogOpen(true);
setDetailTab("session");
setDetail(null);
setDetailError("");
setDetailLoading(true);
@@ -680,8 +690,8 @@ export function HistoryPage() {
</ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogContent className="flex h-[min(94vh,980px)] w-full max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-7xl">
<DialogHeader className="shrink-0 gap-1.5 px-5 py-4 sm:px-6">
<DialogTitle className="flex items-center gap-2">
<MessageSquareText size={18} />
{detail?.assistantName || "对话详情"}
@@ -693,44 +703,84 @@ export function HistoryPage() {
</DialogDescription>
</DialogHeader>
<div className="min-h-0 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-hairline sm:flex-row">
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline sm:w-1/2 sm:border-b-0 sm:border-r">
<SectionAnchorTabs
ariaLabel="会话详情分区"
sections={detailTabs}
activeSectionId={detailTab}
onSelect={(sectionId) =>
setDetailTab(sectionId as DetailTabId)
}
className="bg-popover px-3 pt-2 sm:px-4"
/>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
{detailLoading && (
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground">
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!detailLoading && detailError && (
<div className="flex min-h-48 items-center justify-center text-sm text-destructive">
{detailError}
</div>
)}
{detail && detailTab === "session" && (
<SessionInfoPanel detail={detail} />
)}
{detail && detailTab === "analysis" && <AnalysisPanel />}
</div>
</aside>
<main className="flex min-h-0 min-w-0 w-full flex-1 flex-col sm:w-1/2">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-4 py-2.5 sm:px-5">
<div className="text-sm font-medium text-foreground">
</div>
{detail && (
<div className="flex flex-wrap gap-1.5">
<Badge
variant="secondary"
className="gap-1 bg-surface-strong text-muted-foreground"
>
<MessageSquareText size={11} />
{detail.messages.length}
</Badge>
{detail.messages.some((message) =>
(message.artifacts ?? []).some(
(item) => item.kind === "image",
),
) && (
<Badge
variant="secondary"
className="gap-1 bg-surface-strong text-muted-foreground"
>
<ImageIcon size={11} />
</Badge>
)}
</div>
)}
</div>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
{detailLoading && (
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!detailLoading && detailError && (
<div className="flex min-h-80 items-center justify-center text-destructive">
<div className="flex min-h-48 items-center justify-center text-destructive">
{detailError}
</div>
)}
{detail && (
<div className="space-y-5">
<section className="rounded-xl border border-hairline bg-surface-strong/20">
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{detail && <TranscriptPanel detail={detail} />}
</div>
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
<Metadata label="运行模式" value={detail.runtimeMode} />
<Metadata label="对话时长" value={durationLabel(detail)} />
<Metadata label="状态" value={statusLabel(detail.status)} />
</div>
</section>
<ConversationTimeline detail={detail} />
</div>
)}
</main>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
</DialogFooter>
<HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
</DialogContent>
</Dialog>
</>
@@ -746,26 +796,106 @@ function Metadata({ label, value }: { label: string; value: string }) {
);
}
type TimelineEntry =
| {
kind: "message";
timestamp: string;
order: number;
message: ConversationMessage;
}
| {
kind: "trace";
timestamp: string;
order: number;
event: TraceEvent;
};
function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
const nodes = workflowNodes(detail);
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
const toolCount = trace.filter(
(event) => event.event === "action_started" || event.event === "tool_started",
).length;
const transitionCount = trace.filter(
(event) => event.event === "edge_selected",
).length;
const imageCount = detail.messages.reduce(
(count, message) =>
count +
(message.artifacts ?? []).filter((item) => item.kind === "image").length,
0,
);
function timestampOrder(value: string): number {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
<Metadata label="运行模式" value={detail.runtimeMode} />
<Metadata label="对话时长" value={durationLabel(detail)} />
<Metadata label="状态" value={statusLabel(detail.status)} />
<Metadata label="开始时间" value={formatDate(detail.startedAt)} />
<Metadata label="结束时间" value={formatDate(detail.endedAt)} />
<Metadata label="会话 ID" value={detail.id} />
<Metadata
label="助手 ID"
value={detail.assistantId || "调试会话"}
/>
</div>
<div className="border-t border-hairline pt-4">
<div className="caption-label mb-2 text-muted-soft"></div>
<div className="flex flex-wrap gap-2">
<Badge
variant="secondary"
className="gap-1.5 bg-surface-strong text-muted-foreground"
>
<MessageSquareText size={12} />
{detail.messages.length}
</Badge>
{imageCount > 0 && (
<Badge
variant="secondary"
className="gap-1.5 bg-surface-strong text-muted-foreground"
>
<ImageIcon size={12} />
{imageCount}
</Badge>
)}
{toolCount > 0 && (
<Badge
variant="secondary"
className="gap-1.5 bg-surface-strong text-muted-foreground"
>
<Wrench size={12} />
{toolCount}
</Badge>
)}
{transitionCount > 0 && (
<Badge
variant="secondary"
className="gap-1.5 bg-surface-strong text-muted-foreground"
>
<GitBranch size={12} />
{transitionCount}
</Badge>
)}
{nodes.size > 0 && (
<Badge
variant="secondary"
className="gap-1.5 bg-surface-strong text-muted-foreground"
>
{nodes.size}
</Badge>
)}
</div>
</div>
</div>
);
}
function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
function AnalysisPanel() {
return (
<div className="flex min-h-48 flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-5 py-10 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
<ChartLine size={18} />
</div>
<div className="mt-4 text-sm font-medium text-foreground">
</div>
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
</p>
</div>
);
}
function TranscriptPanel({ detail }: { detail: ConversationDetail }) {
const nodes = workflowNodes(detail);
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
const startedByInvocation = new Map<string, TraceEvent>();
@@ -795,50 +925,22 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
a.order - b.order,
);
const imageCount = detail.messages.reduce(
(count, message) =>
count + (message.artifacts ?? []).filter((item) => item.kind === "image").length,
0,
if (entries.length === 0) {
return (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
</div>
);
const toolCount = trace.filter(
(event) => event.event === "action_started" || event.event === "tool_started",
).length;
const transitionCount = trace.filter((event) => event.event === "edge_selected").length;
}
return (
<section className="overflow-hidden rounded-xl border border-hairline bg-surface-strong/20">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 py-3">
<div className="text-sm font-medium">线</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
<MessageSquareText size={12} />
{detail.messages.length}
</Badge>
{imageCount > 0 && (
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
<ImageIcon size={12} />
{imageCount}
</Badge>
)}
{toolCount > 0 && (
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
<Wrench size={12} />
{toolCount}
</Badge>
)}
{transitionCount > 0 && (
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
<GitBranch size={12} />
{transitionCount}
</Badge>
)}
</div>
</div>
<div className="space-y-4 p-4 sm:p-5">
<div className="space-y-4">
{entries.map((entry, index) =>
entry.kind === "message" ? (
<TimelineMessage key={`message-${entry.message.id}`} message={entry.message} />
<TimelineMessage
key={`message-${entry.message.id}`}
message={entry.message}
/>
) : (
<TimelineTrace
key={`trace-${textValue(entry.event.eventId) || index}`}
@@ -851,16 +953,107 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
/>
),
)}
{entries.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
</div>
)}
</div>
</section>
);
}
function formatPlaybackClock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
/** 底部录音回放区Mock会话录音尚未接入后端时可演示进度。 */
function HistoryPlaybackBar({
detail,
}: {
detail: ConversationDetail | null;
}) {
const durationSec = useMemo(() => {
if (!detail?.endedAt) return 0;
const ms =
new Date(detail.endedAt).getTime() - new Date(detail.startedAt).getTime();
return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : 0;
}, [detail]);
const [playing, setPlaying] = useState(false);
const [elapsed, setElapsed] = useState(0);
const hasRecording = false;
useEffect(() => {
if (!playing || durationSec <= 0) return;
const id = window.setInterval(() => {
setElapsed((current) => {
if (current + 0.2 >= durationSec) {
window.clearInterval(id);
queueMicrotask(() => setPlaying(false));
return durationSec;
}
return current + 0.2;
});
}, 200);
return () => window.clearInterval(id);
}, [playing, durationSec]);
const progress = durationSec > 0 ? Math.min(1, elapsed / durationSec) : 0;
return (
<div className="shrink-0 border-t border-hairline bg-canvas-soft/40">
<div className="flex items-center gap-3 px-4 py-2 sm:px-5">
<Button
type="button"
size="icon-sm"
variant="outline"
className="shrink-0 border-hairline-strong"
disabled={!detail || durationSec <= 0}
aria-label={playing ? "暂停" : "播放"}
onClick={() => {
setPlaying((value) => !value);
}}
>
{playing ? <Pause size={14} /> : <Play size={14} />}
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 text-[11px] tabular-nums text-muted-soft">
<span>{formatPlaybackClock(elapsed)}</span>
<span className="truncate text-muted-foreground">
{hasRecording ? "会话录音" : "录音回放Mock尚未接入录音文件"}
</span>
<span>{formatPlaybackClock(durationSec)}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-150"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
</div>
</div>
);
}
type TimelineEntry =
| {
kind: "message";
timestamp: string;
order: number;
message: ConversationMessage;
}
| {
kind: "trace";
timestamp: string;
order: number;
event: TraceEvent;
};
function timestampOrder(value: string): number {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
}
function TimelineMessage({ message }: { message: ConversationMessage }) {
const isUser = message.role === "user";
const images = (message.artifacts ?? []).filter((item) => item.kind === "image");