需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。 实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
356 lines
23 KiB
TypeScript
356 lines
23 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
||
import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Smartphone, Trash2 } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||
import { AgentCreationDialog, type AgentCreationInput } from '@/components/opencode/AgentCreationDialog';
|
||
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
|
||
import type { ConfiguredModelOption } from '@/lib/model-options';
|
||
import { extractText } from '@/pages/Chat/message-utils';
|
||
import type { ProjectAgentConfig } from '../../../shared/project-config';
|
||
import type { ProjectSessionMetadata } from '../../../shared/project-conversations';
|
||
import type { RawMessage } from '@/types/chat';
|
||
import type { OpencodeSession, OpencodeSessionStatusMap } from '@/types/opencode';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
type ArchiveTarget =
|
||
| { kind: 'agent'; agent: ProjectAgentConfig }
|
||
| { kind: 'session'; sessionId: string; title: string }
|
||
| null;
|
||
|
||
type DeleteTarget =
|
||
| { kind: 'agent'; agent: ProjectAgentConfig; sessionCount: number }
|
||
| { kind: 'session'; sessionId: string; title: string }
|
||
| null;
|
||
|
||
type AgentConversationSidebarProps = {
|
||
agents: ProjectAgentConfig[];
|
||
sessions: OpencodeSession[];
|
||
sessionMessagesBySessionId: Record<string, RawMessage[]>;
|
||
conversationSessions: ProjectSessionMetadata[];
|
||
selectedAgentId: string | null;
|
||
selectedSessionId: string | null;
|
||
sessionStatuses: OpencodeSessionStatusMap;
|
||
pendingQuestionCountBySession: Record<string, number>;
|
||
pendingPermissionCountBySession: Record<string, number>;
|
||
startingAgentId: string | null;
|
||
modelOptions: ConfiguredModelOption[];
|
||
onSelectAgent: (agentId: string) => void;
|
||
onSelectSession: (sessionId: string) => void;
|
||
onNewSession: (agentId: string) => void;
|
||
onCreateAgent: (input: AgentCreationInput) => Promise<void>;
|
||
onArchiveAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||
onRestoreAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||
onDeleteAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||
onArchiveSession: (sessionId: string) => Promise<void>;
|
||
onRestoreSession: (sessionId: string) => Promise<void>;
|
||
onDeleteSession: (sessionId: string) => Promise<void>;
|
||
onTogglePinAgent: (agent: ProjectAgentConfig) => Promise<void>;
|
||
onOpenDevicePreview?: () => void;
|
||
onOpenProjectSettings?: () => void;
|
||
};
|
||
|
||
function getSessionId(session: OpencodeSession): string {
|
||
return String(session.id ?? session.sessionID ?? '');
|
||
}
|
||
|
||
function sessionTimestamp(session: OpencodeSession | undefined, metadata: ProjectSessionMetadata | undefined): number {
|
||
const runtimeTime = session?.time?.updated;
|
||
if (typeof runtimeTime === 'number') return runtimeTime;
|
||
const runtimeUpdated = Date.parse(session?.updatedAt ?? '');
|
||
if (Number.isFinite(runtimeUpdated)) return runtimeUpdated;
|
||
const metadataUpdated = Date.parse(metadata?.updatedAt ?? '');
|
||
return Number.isFinite(metadataUpdated) ? metadataUpdated : 0;
|
||
}
|
||
|
||
function formatSessionTime(timestamp: number): string {
|
||
if (!timestamp) return '';
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
if (date.toDateString() === now.toDateString()) {
|
||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
||
}
|
||
|
||
function sessionTitle(session: OpencodeSession | undefined): string {
|
||
const title = session?.title?.trim() || session?.name?.trim();
|
||
return title || '新对话';
|
||
}
|
||
|
||
function latestMessagePreview(messages: RawMessage[] | undefined, fallback: string): string {
|
||
for (let index = (messages?.length ?? 0) - 1; index >= 0; index -= 1) {
|
||
const message = messages?.[index];
|
||
if (!message) continue;
|
||
const preview = extractText(message).replace(/\s+/gu, ' ').trim();
|
||
if (preview) return preview;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
const SESSION_PREVIEW_LIMIT = 24;
|
||
|
||
function compactSessionPreview(preview: string): string {
|
||
const characters = Array.from(preview);
|
||
return characters.length > SESSION_PREVIEW_LIMIT
|
||
? `${characters.slice(0, SESSION_PREVIEW_LIMIT - 1).join('')}…`
|
||
: preview;
|
||
}
|
||
|
||
export function AgentConversationSidebar({
|
||
agents,
|
||
sessions,
|
||
sessionMessagesBySessionId,
|
||
conversationSessions,
|
||
selectedAgentId,
|
||
selectedSessionId,
|
||
sessionStatuses,
|
||
pendingQuestionCountBySession,
|
||
pendingPermissionCountBySession,
|
||
startingAgentId,
|
||
modelOptions,
|
||
onSelectAgent,
|
||
onSelectSession,
|
||
onNewSession,
|
||
onCreateAgent,
|
||
onArchiveAgent,
|
||
onRestoreAgent,
|
||
onDeleteAgent,
|
||
onArchiveSession,
|
||
onRestoreSession,
|
||
onDeleteSession,
|
||
onTogglePinAgent,
|
||
onOpenDevicePreview,
|
||
onOpenProjectSettings,
|
||
}: AgentConversationSidebarProps) {
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
|
||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [expandedSessionLists, setExpandedSessionLists] = useState<Record<string, boolean>>({});
|
||
|
||
const runtimeBySessionId = useMemo(
|
||
() => new Map(sessions.map((session) => [getSessionId(session), session])),
|
||
[sessions],
|
||
);
|
||
const sessionsByAgentId = useMemo(() => {
|
||
const grouped = new Map<string, ProjectSessionMetadata[]>();
|
||
for (const metadata of conversationSessions) {
|
||
const current = grouped.get(metadata.agentId) ?? [];
|
||
current.push(metadata);
|
||
grouped.set(metadata.agentId, current);
|
||
}
|
||
return grouped;
|
||
}, [conversationSessions]);
|
||
|
||
const getAgentSessions = useCallback((agentId: string) => {
|
||
const metadata = sessionsByAgentId.get(agentId) ?? [];
|
||
return [...metadata].sort((left, right) => {
|
||
const leftArchived = Boolean(left.archivedAt);
|
||
const rightArchived = Boolean(right.archivedAt);
|
||
if (leftArchived !== rightArchived) return leftArchived ? 1 : -1;
|
||
return sessionTimestamp(runtimeBySessionId.get(right.sessionId), right)
|
||
- sessionTimestamp(runtimeBySessionId.get(left.sessionId), left);
|
||
});
|
||
}, [runtimeBySessionId, sessionsByAgentId]);
|
||
|
||
const agentActivity = useCallback((agent: ProjectAgentConfig) => {
|
||
const agentSessions = getAgentSessions(agent.id);
|
||
const unread = agentSessions.reduce((total, item) => total + item.unreadCount, 0);
|
||
const running = agentSessions.some((item) => {
|
||
const type = sessionStatuses[item.sessionId]?.type;
|
||
return type === 'busy' || type === 'retry';
|
||
});
|
||
const pending = agentSessions.reduce(
|
||
(total, item) => total + (pendingQuestionCountBySession[item.sessionId] ?? 0) + (pendingPermissionCountBySession[item.sessionId] ?? 0),
|
||
0,
|
||
);
|
||
const latest = agentSessions[0];
|
||
return { unread, running, pending, latestAt: latest ? sessionTimestamp(runtimeBySessionId.get(latest.sessionId), latest) : 0 };
|
||
}, [getAgentSessions, pendingPermissionCountBySession, pendingQuestionCountBySession, runtimeBySessionId, sessionStatuses]);
|
||
|
||
const activeAgents = useMemo(
|
||
() => agents
|
||
.filter((agent) => !agent.archivedAt)
|
||
.sort((left, right) => {
|
||
if (Boolean(left.pinned) !== Boolean(right.pinned)) return left.pinned ? -1 : 1;
|
||
return agentActivity(right).latestAt - agentActivity(left).latestAt;
|
||
}),
|
||
[agentActivity, agents],
|
||
);
|
||
const archivedAgents = useMemo(() => agents.filter((agent) => Boolean(agent.archivedAt)), [agents]);
|
||
|
||
const getAgentStatus = (activity: ReturnType<typeof agentActivity>) => {
|
||
if (activity.pending > 0) return { label: '处理中', dot: 'bg-amber-500 animate-pulse' };
|
||
if (activity.running) return { label: '处理中', dot: 'bg-emerald-500 animate-pulse' };
|
||
if (activity.unread > 0) return { label: '有新消息', dot: 'bg-brand' };
|
||
return null;
|
||
};
|
||
|
||
const renderAgentSessions = (agent: ProjectAgentConfig) => {
|
||
const agentSessions = getAgentSessions(agent.id);
|
||
const activeSessions = agentSessions.filter((item) => !item.archivedAt);
|
||
const visibleSessions = activeSessions.slice(0, 5);
|
||
const hiddenSessions = activeSessions.slice(5);
|
||
const archivedSessions = agentSessions.filter((item) => Boolean(item.archivedAt));
|
||
const renderSession = (metadata: ProjectSessionMetadata) => {
|
||
const runtimeSession = runtimeBySessionId.get(metadata.sessionId);
|
||
const title = sessionTitle(runtimeSession);
|
||
const preview = compactSessionPreview(latestMessagePreview(sessionMessagesBySessionId[metadata.sessionId], title));
|
||
const selected = metadata.sessionId === selectedSessionId;
|
||
const pending = (pendingQuestionCountBySession[metadata.sessionId] ?? 0) + (pendingPermissionCountBySession[metadata.sessionId] ?? 0);
|
||
return (
|
||
<div
|
||
key={metadata.sessionId}
|
||
className={cn(
|
||
'group/session-card flex w-full items-center gap-1 rounded-lg px-1 py-0.5',
|
||
selected ? 'bg-background shadow-soft' : 'hover:bg-background/70',
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-current={selected ? 'page' : undefined}
|
||
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left"
|
||
onClick={() => onSelectSession(metadata.sessionId)}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold" title={latestMessagePreview(sessionMessagesBySessionId[metadata.sessionId], title)}>
|
||
{preview}
|
||
</span>
|
||
{metadata.unreadCount > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{metadata.unreadCount > 99 ? '99+' : metadata.unreadCount}</Badge> : pending > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{pending}</Badge> : null}
|
||
<span className="shrink-0 whitespace-nowrap text-[10px] text-muted-foreground">
|
||
{formatSessionTime(sessionTimestamp(runtimeSession, metadata)) || '刚刚'}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="pointer-events-none shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-surface-subtle hover:text-destructive group-hover/session-card:pointer-events-auto group-hover/session-card:opacity-100"
|
||
aria-label={`归档会话 ${title}`}
|
||
title="归档会话"
|
||
onClick={() => setArchiveTarget({ kind: 'session', sessionId: metadata.sessionId, title })}
|
||
>
|
||
<Archive className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
return (
|
||
<section className="border-t border-brand/15 px-2 pb-1.5 pt-1" aria-label={`${agent.name || '联系人'}的会话列表`}>
|
||
<div className="flex items-center justify-end">
|
||
<button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label="新建会话" title="新建会话" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" /></button>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
{visibleSessions.map(renderSession)}
|
||
{activeSessions.length === 0 ? <button type="button" className="flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 px-2.5 py-2.5 text-left text-xs text-muted-foreground hover:bg-background" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" />开始一条新对话</button> : null}
|
||
{hiddenSessions.length > 0 ? <details className="pt-1" open={expandedSessionLists[agent.id] ?? hiddenSessions.some((item) => item.sessionId === selectedSessionId)} onToggle={(event) => setExpandedSessionLists((current) => ({ ...current, [agent.id]: event.currentTarget.open }))}><summary className="cursor-pointer px-2.5 py-1 text-[10px] font-semibold text-muted-foreground">更多会话({hiddenSessions.length})</summary><div className="mt-0.5 space-y-0.5">{hiddenSessions.map(renderSession)}</div></details> : null}
|
||
{archivedSessions.length > 0 ? <details className="pt-2"><summary className="cursor-pointer px-2.5 text-[10px] font-semibold text-muted-foreground">已归档会话({archivedSessions.length})</summary><div className="mt-1 space-y-1">{archivedSessions.map((metadata) => { const title = sessionTitle(runtimeBySessionId.get(metadata.sessionId)); return <div key={metadata.sessionId} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1 truncate" title={title}>{compactSessionPreview(title)}</span><button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label={`恢复会话 ${title}`} onClick={() => void onRestoreSession(metadata.sessionId)}><RotateCcw className="h-3 w-3" /></button><button type="button" className="rounded p-1 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除会话 ${title}`} title="永久删除会话" onClick={() => setDeleteTarget({ kind: 'session', sessionId: metadata.sessionId, title })}><Trash2 className="h-3 w-3" /></button></div>; })}</div></details> : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const handleArchiveConfirm = async () => {
|
||
if (!archiveTarget) return;
|
||
if (archiveTarget.kind === 'agent') await onArchiveAgent(archiveTarget.agent);
|
||
else await onArchiveSession(archiveTarget.sessionId);
|
||
setArchiveTarget(null);
|
||
};
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deleteTarget) return;
|
||
if (deleteTarget.kind === 'agent') await onDeleteAgent(deleteTarget.agent);
|
||
else await onDeleteSession(deleteTarget.sessionId);
|
||
setDeleteTarget(null);
|
||
};
|
||
|
||
return (
|
||
<aside className="flex h-full min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-none border-r border-border/80 bg-surface-tertiary text-foreground lg:w-[19rem]" data-testid="agent-conversation-sidebar">
|
||
<div className="relative flex shrink-0 items-center border-b border-border/70 bg-background/70 px-3 py-3 pr-28">
|
||
<h2 className="min-w-0 truncate text-left text-base font-semibold">我的项目空间</h2>
|
||
<div className="absolute right-3 flex items-center gap-1">
|
||
{onOpenDevicePreview ? (
|
||
<Button type="button" size="icon" variant="ghost" className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]" aria-label="真机预览" title="真机预览" onClick={onOpenDevicePreview}>
|
||
<Smartphone className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
{onOpenProjectSettings ? (
|
||
<Button type="button" size="icon" variant="ghost" className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]" aria-label="项目设置" title="项目设置" onClick={onOpenProjectSettings}>
|
||
<SettingsIcon className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
<Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建联系人" onClick={() => { if (modelOptions.length === 0) { toast.error('请先配置至少一个可用模型。'); return; } setCreateOpen(true); }}>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-0" data-testid="agent-conversation-sidebar-content">
|
||
<div className="space-y-1">
|
||
{activeAgents.map((agent) => {
|
||
const activity = agentActivity(agent);
|
||
const selected = agent.id === selectedAgentId;
|
||
const status = getAgentStatus(activity);
|
||
return (
|
||
<div key={agent.id} className={cn('group/agent-card relative overflow-hidden rounded-none border transition-colors', selected ? 'border-brand/45 bg-brand-soft shadow-soft before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-brand' : 'border-transparent hover:border-border/70 hover:bg-background')}>
|
||
<div className="relative">
|
||
<button type="button" className="flex w-full min-w-0 items-center gap-2.5 px-2.5 py-2.5 pr-12 text-left" aria-pressed={selected} aria-expanded={selected} aria-busy={startingAgentId === agent.id} title={selected ? '收起最近会话' : '展开最近会话'} data-testid={`project-agent-chat-${agent.id}`} disabled={startingAgentId === agent.id} onClick={() => onSelectAgent(agent.id)}>
|
||
<span className="relative shrink-0"><img src={getAgentAvatarSrc(agent.avatarId)} alt="" className="h-11 w-11 rounded-xl border border-border object-cover [image-rendering:pixelated]" /></span>
|
||
<span className="min-w-0 flex-1"><span className="flex items-center gap-1.5"><span className="truncate text-sm font-semibold">{agent.name || '未命名联系人'}</span>{agent.pinned ? <Pin className="h-3 w-3 shrink-0 text-brand" /> : null}</span><span className="mt-0.5 block truncate text-[11px] text-muted-foreground">{agent.responsibility.mission || '还没有填写职责'}</span></span>
|
||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||
<span data-testid={`agent-status-row-${agent.id}`} className="inline-flex items-center gap-1">
|
||
{status ? <span className="inline-flex max-w-24 items-center gap-1 text-[10px] font-medium text-muted-foreground" title={status.label}><span aria-hidden="true" className={cn('h-2 w-2 shrink-0 rounded-full', status.dot)} /><span className="truncate">{status.label}</span></span> : null}
|
||
{activity.unread > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{activity.unread > 99 ? '99+' : activity.unread}</Badge> : activity.pending > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{activity.pending}</Badge> : null}
|
||
</span>
|
||
{activity.latestAt ? <span className="text-[10px] text-muted-foreground">{formatSessionTime(activity.latestAt)}</span> : null}
|
||
</span>
|
||
</button>
|
||
<div className="absolute right-1 top-1/2 hidden -translate-y-1/2 flex-col gap-0.5 rounded-md bg-background/90 p-0.5 shadow-soft group-hover/agent-card:flex">
|
||
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-brand" aria-label={agent.pinned ? '取消置顶联系人' : '置顶联系人'} title={agent.pinned ? '取消置顶' : '置顶'} onClick={() => void onTogglePinAgent(agent)}><Pin className={cn('h-3.5 w-3.5', agent.pinned && 'fill-current text-brand')} /></button>
|
||
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-destructive" aria-label={`归档联系人 ${agent.name || '未命名联系人'}`} title="归档联系人" onClick={() => setArchiveTarget({ kind: 'agent', agent })}><Archive className="h-3.5 w-3.5" /></button>
|
||
</div>
|
||
</div>
|
||
{selected ? renderAgentSessions(agent) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
{activeAgents.length === 0 ? <div className="rounded-xl border border-dashed border-border/80 bg-background/60 p-5 text-center text-xs leading-5 text-muted-foreground">还没有联系人<br />点击右上角的 + 创建第一个</div> : null}
|
||
</div>
|
||
|
||
{archivedAgents.length > 0 ? <section className="mt-3 border-t border-border/70 pt-3"><button type="button" className="flex w-full items-center justify-between px-1 text-xs font-semibold text-muted-foreground" onClick={() => setShowArchived((current) => !current)}><span>已归档联系人({archivedAgents.length})</span><ChevronRight className={cn('h-3.5 w-3.5 transition-transform', showArchived && 'rotate-90')} /></button>{showArchived ? <div className="mt-1 space-y-1">{archivedAgents.map((agent) => <div key={agent.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><img src={getAgentAvatarSrc(agent.avatarId)} alt="" className="h-7 w-7 rounded-lg object-cover [image-rendering:pixelated]" /><span className="min-w-0 flex-1 truncate">{agent.name || '未命名联系人'}</span><button type="button" className="rounded p-1.5 hover:bg-background hover:text-brand" aria-label={`恢复联系人 ${agent.name || '未命名联系人'}`} onClick={() => void onRestoreAgent(agent)}><RotateCcw className="h-3.5 w-3.5" /></button>{!agent.builtIn ? <button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除联系人 ${agent.name || '未命名联系人'}`} title="永久删除联系人" onClick={() => setDeleteTarget({ kind: 'agent', agent, sessionCount: getAgentSessions(agent.id).length })}><Trash2 className="h-3.5 w-3.5" /></button> : null}</div>)}</div> : null}</section> : null}
|
||
</div>
|
||
|
||
<AgentCreationDialog
|
||
open={createOpen}
|
||
onOpenChange={setCreateOpen}
|
||
modelOptions={modelOptions}
|
||
existingAgentNames={agents.map((agent) => agent.name)}
|
||
onCreate={onCreateAgent}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(archiveTarget)}
|
||
title={archiveTarget?.kind === 'agent' ? `归档联系人“${archiveTarget.agent.name || '未命名联系人'}”?` : `归档会话“${archiveTarget?.title ?? ''}”?`}
|
||
message={archiveTarget?.kind === 'agent' ? '归档会停止这个联系人下正在运行的所有会话,但会保留联系人、会话记录和未完成内容,之后可以在底部恢复。' : '归档会停止这个会话并保留全部历史,不会影响同一联系人的其他会话。'}
|
||
confirmLabel="确认归档"
|
||
cancelLabel="取消"
|
||
onCancel={() => setArchiveTarget(null)}
|
||
onConfirm={() => void handleArchiveConfirm()}
|
||
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
||
/>
|
||
<ConfirmDialog
|
||
open={Boolean(deleteTarget)}
|
||
title={deleteTarget?.kind === 'agent' ? `永久删除联系人“${deleteTarget.agent.name || '未命名联系人'}”?` : `永久删除会话“${deleteTarget?.title ?? ''}”?`}
|
||
message={deleteTarget?.kind === 'agent'
|
||
? `这会永久删除联系人及其 ${deleteTarget.sessionCount} 条会话记录,删除后无法恢复。`
|
||
: '这会永久删除会话及其全部历史记录,删除后无法恢复。'}
|
||
confirmLabel="永久删除"
|
||
cancelLabel="取消"
|
||
variant="destructive"
|
||
onCancel={() => setDeleteTarget(null)}
|
||
onConfirm={() => void handleDeleteConfirm()}
|
||
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
||
/>
|
||
</aside>
|
||
);
|
||
}
|