Files
makelore/src/components/opencode/AgentConversationSidebar.tsx
brother7 1a19ad9808 实现项目真机预览与待审版本扫码验收
需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。

实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
2026-08-08 17:57:36 +08:00

356 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}