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; conversationSessions: ProjectSessionMetadata[]; selectedAgentId: string | null; selectedSessionId: string | null; sessionStatuses: OpencodeSessionStatusMap; pendingQuestionCountBySession: Record; pendingPermissionCountBySession: Record; startingAgentId: string | null; modelOptions: ConfiguredModelOption[]; onSelectAgent: (agentId: string) => void; onSelectSession: (sessionId: string) => void; onNewSession: (agentId: string) => void; onCreateAgent: (input: AgentCreationInput) => Promise; onArchiveAgent: (agent: ProjectAgentConfig) => Promise; onRestoreAgent: (agent: ProjectAgentConfig) => Promise; onDeleteAgent: (agent: ProjectAgentConfig) => Promise; onArchiveSession: (sessionId: string) => Promise; onRestoreSession: (sessionId: string) => Promise; onDeleteSession: (sessionId: string) => Promise; onTogglePinAgent: (agent: ProjectAgentConfig) => Promise; 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(null); const [deleteTarget, setDeleteTarget] = useState(null); const [showArchived, setShowArchived] = useState(false); const [expandedSessionLists, setExpandedSessionLists] = useState>({}); const runtimeBySessionId = useMemo( () => new Map(sessions.map((session) => [getSessionId(session), session])), [sessions], ); const sessionsByAgentId = useMemo(() => { const grouped = new Map(); 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) => { 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 (
); }; return (
{visibleSessions.map(renderSession)} {activeSessions.length === 0 ? : null} {hiddenSessions.length > 0 ?
item.sessionId === selectedSessionId)} onToggle={(event) => setExpandedSessionLists((current) => ({ ...current, [agent.id]: event.currentTarget.open }))}>更多会话({hiddenSessions.length})
{hiddenSessions.map(renderSession)}
: null} {archivedSessions.length > 0 ?
已归档会话({archivedSessions.length})
{archivedSessions.map((metadata) => { const title = sessionTitle(runtimeBySessionId.get(metadata.sessionId)); return
{compactSessionPreview(title)}
; })}
: null}
); }; 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 ( ); }