feat: add project-aware agent workspace
This commit is contained in:
@@ -1,333 +1,196 @@
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, FolderInput, MoreHorizontal, Pencil, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
|
||||
|
||||
type AgentHistoryPanelProps = {
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
scopeLabel: string;
|
||||
activeSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingSessionId: string | null;
|
||||
sessions: AgentChatSessionSummary[];
|
||||
topics?: AgentTopic[];
|
||||
topicId?: string;
|
||||
onTopicChange?: (id: string | undefined) => void;
|
||||
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
|
||||
query?: string;
|
||||
onQueryChange?: (value: string) => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
error?: string;
|
||||
pendingUpdate?: boolean;
|
||||
onLoadMore?: () => Promise<unknown> | void;
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AgentHistoryPanel({
|
||||
activeSessionId,
|
||||
loading,
|
||||
loadingSessionId,
|
||||
sessions,
|
||||
onRefresh,
|
||||
onSelectSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
}: AgentHistoryPanelProps) {
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
||||
|
||||
const beginRename = (session: AgentChatSessionSummary) => {
|
||||
setEditingSessionId(session.id);
|
||||
setDraftTitle(session.title);
|
||||
export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel, activeSessionId, loading, loadingSessionId,
|
||||
sessions, query = "", onQueryChange, hasMore, loadingMore, error, pendingUpdate, onLoadMore, onRefresh,
|
||||
topics = [], topicId, onTopicChange, onMoveSessionToTopic, onSelectSession, onRenameSession, onDeleteSession }: Props) {
|
||||
const [search, setSearch] = useState(query);
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [viewport, setViewport] = useState<{ height: number; top: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const visual = window.visualViewport;
|
||||
const update = () => {
|
||||
const next = { height: visual?.height ?? window.innerHeight, top: visual?.offsetTop ?? 0 };
|
||||
setViewport((current) => current?.height === next.height && current.top === next.top ? current : next);
|
||||
};
|
||||
update();
|
||||
visual?.addEventListener("resize", update);
|
||||
visual?.addEventListener("scroll", update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
visual?.removeEventListener("resize", update);
|
||||
visual?.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open || search === query) return;
|
||||
const timer = setTimeout(() => onQueryChange?.(search.trim()), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onQueryChange, open, query, search]);
|
||||
const perform = async (id: string, action: () => Promise<void> | void) => {
|
||||
setBusy(id); setActionError(null);
|
||||
try { await action(); setEditingId(null); setDeletingId(null); }
|
||||
catch (reason) { setActionError(reason instanceof Error ? reason.message : "操作失败,请重试"); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
const saveRename = (session: AgentChatSessionSummary) => {
|
||||
const normalizedTitle = draftTitle.trim();
|
||||
if (!normalizedTitle || normalizedTitle === session.title) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSessionId(session.id);
|
||||
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
||||
.then(cancelRename)
|
||||
.finally(() => setSavingSessionId(null));
|
||||
};
|
||||
|
||||
const confirmDelete = (sessionId: string) => {
|
||||
setDeletingSessionId(sessionId);
|
||||
void Promise.resolve(onDeleteSession?.(sessionId))
|
||||
.then(() => setConfirmingDeleteSessionId(null))
|
||||
.finally(() => setDeletingSessionId(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
||||
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||
</p>
|
||||
let previousGroup = "";
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showCloseButton={false} data-agent-theme="chat"
|
||||
overlayClassName="agent-history-overlay motion-reduce:animate-none"
|
||||
className="agent-history-dialog flex flex-col gap-0 overflow-hidden bg-card p-0 text-foreground motion-reduce:animate-none"
|
||||
style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined}
|
||||
onOpenAutoFocus={(event) => { event.preventDefault(); searchRef.current?.focus(); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
|
||||
<div data-agent-history-header className="shrink-0 space-y-4 p-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<DialogHeader className="min-w-0 text-left">
|
||||
<DialogTitle>历史对话</DialogTitle>
|
||||
<DialogDescription className="break-words">{scopeLabel}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" aria-label="刷新 Agent 历史记录" loading={loadingMore}
|
||||
onClick={() => void perform("refresh", () => onRefresh?.())}><RefreshCw aria-hidden="true" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭历史对话" onClick={() => onOpenChange(false)}><X aria-hidden="true" /></Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={loading && sessions.length === 0}
|
||||
loading={loading && sessions.length > 0}
|
||||
loadingLabel="正在刷新 Agent 历史记录"
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
正在加载
|
||||
</motion.div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
const active = session.id === activeSessionId;
|
||||
const itemLoading = loadingSessionId === session.id;
|
||||
const editing = editingSessionId === session.id;
|
||||
const saving = savingSessionId === session.id;
|
||||
const deleting = deletingSessionId === session.id;
|
||||
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||
active && !editing
|
||||
? "agent-history-item-active border border-[var(--surface-border)]"
|
||||
: "border border-transparent"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div
|
||||
key="confirm-delete"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<span
|
||||
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||
title={session.title}
|
||||
>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||
删除后无法从历史记录中恢复
|
||||
</span>
|
||||
</motion.div>
|
||||
) : editing ? (
|
||||
<motion.form
|
||||
key="rename"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveRename(session);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="agent-history-session-trigger min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span
|
||||
className="block truncate text-xs font-semibold text-slate-900"
|
||||
title={session.title}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||
{formatSessionUpdatedAt(session.updatedAt)}
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-2">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<StatusDot tone="info" activity="live" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2
|
||||
size={13}
|
||||
className="animate-spin text-[var(--action-blue-hover)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<StatusBadge tone="info">当前</StatusBadge>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={deleting}
|
||||
loadingLabel="正在删除对话"
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
size="icon-sm"
|
||||
loading={saving}
|
||||
loadingLabel="正在保存对话主题"
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
setConfirmingDeleteSessionId(session.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div
|
||||
key="history-empty"
|
||||
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<div className="relative">
|
||||
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input ref={searchRef} type="search" maxLength={200} aria-label="搜索标题和对话内容" placeholder="搜索标题或对话内容"
|
||||
value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" />
|
||||
</div>
|
||||
{onTopicChange || pendingUpdate ? <div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{onTopicChange ? <DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline" size="sm" aria-label="筛选历史主题" className="max-w-full">
|
||||
<FolderInput aria-hidden="true" /><span className="truncate">{topicId === "unclassified" ? "未分类" : topics.find((topic) => topic.id === topicId)?.name ?? "全部主题"}</span><ChevronDown aria-hidden="true" />
|
||||
</Button></DropdownMenuTrigger><DropdownMenuContent data-agent-theme="chat" align="start" className="z-[112] max-h-64 overflow-y-auto">
|
||||
<DropdownMenuRadioGroup value={topicId ?? "all"} onValueChange={(id) => onTopicChange(id === "all" ? undefined : id)}>
|
||||
<DropdownMenuRadioItem value="all">全部主题</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
|
||||
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>{topic.name}</DropdownMenuRadioItem>)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent></DropdownMenu> : null}
|
||||
{pendingUpdate ? <Button variant="link" className="text-xs" onClick={() => void perform("refresh", () => onRefresh?.())}>
|
||||
历史记录有更新,点击刷新
|
||||
</Button> : null}
|
||||
</div> : null}
|
||||
{error || actionError ? <p role="alert" className="text-sm text-destructive">{actionError ?? error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Separator />
|
||||
<div role="region" aria-label="Agent 历史记录" aria-busy={loading || undefined}
|
||||
className="agent-history-list min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-4 sm:px-4">
|
||||
{loading && !sessions.length ? <p role="status" className="px-3 py-8 text-center text-sm text-muted-foreground">正在加载历史对话…</p>
|
||||
: !sessions.length ? <p className="px-3 py-8 text-center text-sm text-muted-foreground">{error ? "历史记录暂不可用,请刷新重试" : query ? "没有找到匹配的会话" : "暂无历史记录"}</p> : null}
|
||||
{sessions.map((session) => {
|
||||
const group = historyGroup(session.updatedAt);
|
||||
const showGroup = group !== previousGroup;
|
||||
previousGroup = group;
|
||||
const active = session.id === activeSessionId;
|
||||
const editing = session.id === editingId;
|
||||
const deleting = session.id === deletingId;
|
||||
const itemBusy = busy === session.id || loadingSessionId === session.id;
|
||||
return <div key={session.id}>
|
||||
{showGroup ? <h3 className="px-3 pb-2 pt-4 text-xs font-medium text-muted-foreground">{group}</h3> : null}
|
||||
<div className={cn("agent-history-item flex items-center gap-3 rounded-md p-3", active ? "agent-history-item-active bg-accent" : "hover:bg-accent/50")}>
|
||||
{editing ? <form className="flex min-w-0 flex-1 flex-wrap items-center gap-2" onSubmit={(event) => {
|
||||
event.preventDefault(); if (title.trim()) void perform(session.id, () => onRenameSession?.(session.id, title.trim()));
|
||||
}} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditingId(null); } }}>
|
||||
<Input autoFocus aria-label="会话标题" value={title} disabled={itemBusy} onChange={(event) => setTitle(event.target.value)} className="min-w-0 flex-1" />
|
||||
<Button type="submit" size="icon" aria-label="保存对话标题" loading={itemBusy} disabled={!title.trim()}><Check aria-hidden="true" /></Button>
|
||||
<Button type="button" size="icon" variant="ghost" aria-label="取消重命名" onClick={() => setEditingId(null)}><X aria-hidden="true" /></Button>
|
||||
</form> : movingId === session.id ? <div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">移入主题</p>
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
|
||||
{[{ id: "", name: "未分类" }, ...topics].map((topic) => <Button key={topic.id} variant="ghost" disabled={Boolean(busy)} className="h-auto min-h-9 justify-start whitespace-normal break-words text-left" onClick={() => void perform(session.id, async () => {
|
||||
await onMoveSessionToTopic?.(session.id, topic.id || null); setMovingId(null);
|
||||
})}>{topic.name}</Button>)}
|
||||
</div><Button size="sm" variant="ghost" onClick={() => setMovingId(null)}>取消移动</Button>
|
||||
</div> : deleting ? <div className="flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm">删除“{session.title}”?</p><p className="mt-1 text-xs text-muted-foreground">删除后无法从历史记录中恢复</p></div>
|
||||
<Button variant="destructive" loading={itemBusy} onClick={() => void perform(session.id, () => onDeleteSession?.(session.id))}>确认</Button>
|
||||
<Button variant="ghost" size="icon" aria-label="取消删除" onClick={() => setDeletingId(null)}><X aria-hidden="true" /></Button>
|
||||
</div> : <>
|
||||
<button type="button" className="agent-history-session-trigger min-w-0 flex-1 rounded-sm text-left outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-current={active ? "true" : undefined}
|
||||
disabled={itemBusy || Boolean(loadingSessionId)} onClick={() => onSelectSession(session.id)}>
|
||||
<span className="line-clamp-2 break-words text-sm font-medium leading-6 text-foreground" title={session.title}>{session.title}</span>
|
||||
<span className="mt-1 block truncate text-xs text-muted-foreground">{topics.find((topic) => topic.id === session.topicId)?.name ?? session.topicName ?? "未分类"}</span>
|
||||
{session.searchSnippet ? <span className="mt-1 line-clamp-2 break-words text-xs leading-5 text-muted-foreground">{session.searchSnippet}</span> : null}
|
||||
<span className="mt-1 block text-xs tabular-nums text-muted-foreground">{formatUpdatedAt(session.updatedAt)}{itemBusy ? " · 正在打开" : ""}</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
{session.purpose === "task_collaboration" ? <StatusBadge tone="neutral">任务协作</StatusBadge> : null}
|
||||
{active ? <StatusBadge tone="info">当前</StatusBadge> : null}
|
||||
{session.needsInput ? <StatusBadge tone="warning">需要确认</StatusBadge> : null}
|
||||
{session.isStreaming || session.runStatus === "running" ? <StatusBadge tone="info">处理中</StatusBadge> : null}
|
||||
{session.runStatus === "error" ? <StatusBadge tone="danger">处理失败</StatusBadge> : null}
|
||||
</span>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button size="icon" variant="ghost" disabled={Boolean(loadingSessionId)} aria-label={`会话操作:${session.title}`}><MoreHorizontal aria-hidden="true" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent data-agent-theme="chat" align="end" className="z-[112]">
|
||||
<DropdownMenuItem disabled={!onRenameSession} onSelect={() => { setEditingId(session.id); setDeletingId(null); setTitle(session.title); }}><Pencil aria-hidden="true" />重命名对话</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!onMoveSessionToTopic} onSelect={() => { setMovingId(session.id); setEditingId(null); setDeletingId(null); }}><FolderInput aria-hidden="true" />移入主题</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!onDeleteSession} onSelect={() => { setDeletingId(session.id); setEditingId(null); }}><Trash2 aria-hidden="true" />删除对话</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
{hasMore ? <div className="px-3 pt-4"><Button className="w-full" variant="outline" loading={loadingMore} onClick={() => void onLoadMore?.()}>加载更多</Button></div> : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function formatSessionUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
function historyGroup(value: number) {
|
||||
const now = new Date();
|
||||
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const date = new Date(value);
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime();
|
||||
const week = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime();
|
||||
return start >= day ? "今天" : start === yesterday ? "昨天" : start >= week ? "近 7 天" : "更早";
|
||||
}
|
||||
function formatUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user