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 { @layer base {
* { * {
@apply border-border outline-ring/50; @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 { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
letter-spacing: 0.01em; letter-spacing: 0.01em;
@@ -261,42 +298,9 @@
} }
} }
/* Explicit opt-in alias; base layer already styles all scrollbars. */
.scrollbar-subtle { .scrollbar-subtle {
scrollbar-width: thin; 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>
</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"> <div className="space-y-1">
<NavButton <NavButton
active={pathname === "/"} active={pathname === "/"}

View File

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

View File

@@ -4,6 +4,7 @@ import {
Activity, Activity,
ArrowRight, ArrowRight,
Camera, Camera,
ChartLine,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -15,6 +16,8 @@ import {
MessageSquareText, MessageSquareText,
MonitorSmartphone, MonitorSmartphone,
MoreHorizontal, MoreHorizontal,
Pause,
Play,
Server, Server,
Trash2, Trash2,
Wrench, Wrench,
@@ -23,6 +26,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list"; import { DataList, type DataListColumn } from "@/components/ui/data-list";
@@ -34,10 +38,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
@@ -61,6 +63,12 @@ const PAGE_SIZE = 5;
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const; const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
type ChannelFilter = (typeof channelFilters)[number]; type ChannelFilter = (typeof channelFilters)[number];
type SortOrder = "newest" | "oldest"; type SortOrder = "newest" | "oldest";
type DetailTabId = "session" | "analysis";
const detailTabs = [
{ id: "session", label: "会话信息" },
{ id: "analysis", label: "分析" },
] as const;
function formatDate(value?: string | null): string { function formatDate(value?: string | null): string {
if (!value) return "—"; if (!value) return "—";
@@ -364,6 +372,7 @@ export function HistoryPage() {
const [detail, setDetail] = useState<ConversationDetail | null>(null); const [detail, setDetail] = useState<ConversationDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState(""); const [detailError, setDetailError] = useState("");
const [detailTab, setDetailTab] = useState<DetailTabId>("session");
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const skipRowClickRef = useRef(false); const skipRowClickRef = useRef(false);
@@ -401,6 +410,7 @@ export function HistoryPage() {
const openDetail = useCallback(async (conversation: Conversation) => { const openDetail = useCallback(async (conversation: Conversation) => {
setDialogOpen(true); setDialogOpen(true);
setDetailTab("session");
setDetail(null); setDetail(null);
setDetailError(""); setDetailError("");
setDetailLoading(true); setDetailLoading(true);
@@ -680,8 +690,8 @@ export function HistoryPage() {
</ListPageLayout> </ListPageLayout>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <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"> <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> <DialogHeader className="shrink-0 gap-1.5 px-5 py-4 sm:px-6">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<MessageSquareText size={18} /> <MessageSquareText size={18} />
{detail?.assistantName || "对话详情"} {detail?.assistantName || "对话详情"}
@@ -693,44 +703,84 @@ export function HistoryPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </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">
{detailLoading && ( <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">
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground"> <SectionAnchorTabs
<Loader2 size={16} className="animate-spin" /> ariaLabel="会话详情分区"
sections={detailTabs}
</div> activeSectionId={detailTab}
)} onSelect={(sectionId) =>
setDetailTab(sectionId as DetailTabId)
{!detailLoading && detailError && ( }
<div className="flex min-h-80 items-center justify-center text-destructive"> className="bg-popover px-3 pt-2 sm:px-4"
{detailError} />
</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">
{detail && ( <Loader2 size={16} className="animate-spin" />
<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">
</div> </div>
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4"> )}
<Metadata label="接入通道" value={channelLabel(detail.channel)} /> {!detailLoading && detailError && (
<Metadata label="运行模式" value={detail.runtimeMode} /> <div className="flex min-h-48 items-center justify-center text-sm text-destructive">
<Metadata label="对话时长" value={durationLabel(detail)} /> {detailError}
<Metadata label="状态" value={statusLabel(detail.status)} />
</div> </div>
</section> )}
{detail && detailTab === "session" && (
<ConversationTimeline detail={detail} /> <SessionInfoPanel detail={detail} />
)}
{detail && detailTab === "analysis" && <AnalysisPanel />}
</div> </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-48 items-center justify-center text-destructive">
{detailError}
</div>
)}
{detail && <TranscriptPanel detail={detail} />}
</div>
</main>
</div> </div>
<DialogFooter> <HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
@@ -746,26 +796,106 @@ function Metadata({ label, value }: { label: string; value: string }) {
); );
} }
type TimelineEntry = function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
| { const nodes = workflowNodes(detail);
kind: "message"; const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
timestamp: string; const toolCount = trace.filter(
order: number; (event) => event.event === "action_started" || event.event === "tool_started",
message: ConversationMessage; ).length;
} const transitionCount = trace.filter(
| { (event) => event.event === "edge_selected",
kind: "trace"; ).length;
timestamp: string; const imageCount = detail.messages.reduce(
order: number; (count, message) =>
event: TraceEvent; count +
}; (message.artifacts ?? []).filter((item) => item.kind === "image").length,
0,
);
function timestampOrder(value: string): number { return (
const parsed = Date.parse(value); <div className="space-y-4">
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed; <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 nodes = workflowNodes(detail);
const trace = (detail.extra.workflowTrace ?? []).map(recordValue); const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
const startedByInvocation = new Map<string, TraceEvent>(); const startedByInvocation = new Map<string, TraceEvent>();
@@ -795,72 +925,135 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
a.order - b.order, a.order - b.order,
); );
const imageCount = detail.messages.reduce( if (entries.length === 0) {
(count, message) => return (
count + (message.artifacts ?? []).filter((item) => item.kind === "image").length, <div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
0,
); </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 ( return (
<section className="overflow-hidden rounded-xl border border-hairline bg-surface-strong/20"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 py-3"> {entries.map((entry, index) =>
<div className="text-sm font-medium">线</div> entry.kind === "message" ? (
<div className="flex flex-wrap gap-2"> <TimelineMessage
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground"> key={`message-${entry.message.id}`}
<MessageSquareText size={12} /> message={entry.message}
{detail.messages.length} />
</Badge> ) : (
{imageCount > 0 && ( <TimelineTrace
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground"> key={`trace-${textValue(entry.event.eventId) || index}`}
<ImageIcon size={12} /> event={entry.event}
{imageCount} nodes={nodes}
</Badge> startedEvent={startedByInvocation.get(
)} textValue(recordValue(entry.event.outcome).invocationId) ||
{toolCount > 0 && ( textValue(entry.event.invocationId),
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground"> )}
<Wrench size={12} /> />
{toolCount} ),
</Badge> )}
)} </div>
{transitionCount > 0 && ( );
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground"> }
<GitBranch size={12} />
{transitionCount} function formatPlaybackClock(seconds: number): string {
</Badge> 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> </div>
</div>
<div className="space-y-4 p-4 sm:p-5">
{entries.map((entry, index) =>
entry.kind === "message" ? (
<TimelineMessage key={`message-${entry.message.id}`} message={entry.message} />
) : (
<TimelineTrace
key={`trace-${textValue(entry.event.eventId) || index}`}
event={entry.event}
nodes={nodes}
startedEvent={startedByInvocation.get(
textValue(recordValue(entry.event.outcome).invocationId) ||
textValue(entry.event.invocationId),
)}
/>
),
)}
{entries.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
</div>
)}
</div>
</section>
); );
} }
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 }) { function TimelineMessage({ message }: { message: ConversationMessage }) {
const isUser = message.role === "user"; const isUser = message.role === "user";
const images = (message.artifacts ?? []).filter((item) => item.kind === "image"); const images = (message.artifacts ?? []).filter((item) => item.kind === "image");