import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bot, CircleAlert, LoaderCircle, MessageSquarePlus, RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CODING_ATTACHMENT_MAX_BYTES, CODING_ATTACHMENT_MAX_COUNT, CODING_ATTACHMENT_MIMES, CODING_ATTACHMENT_UPLOAD_CONCURRENCY, uploadCodingAttachment, type CodingAttachmentRef, } from '@/lib/coding-attachments'; import { forkCodingConversation } from '@/lib/coding-conversations'; import { cn } from '@/lib/utils'; import { codingConversationStore, selectCodingConversationDraft, selectCodingConversationRequests, selectCodingConversationSnapshot, useCodingConversationStore, type CodingConversationStoreState, } from '@/stores/coding-conversations'; import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { CodingDraftAttachment, PromptMode, } from '@/types/coding-conversation'; import type { CodingConversationMetadata, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; import { CodingWorkspaceInspector } from './CodingWorkspaceInspector'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export interface CodingChatPanelProps { navigationDraft?: string; onOpenProjectSettings?(): void; } interface LocalComposerAttachment { id: string; name: string; file: File; previewUrl: string; } function newestConversation( conversations: CodingConversationMetadata[], agentId: string, ): CodingConversationMetadata | null { return conversations .filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null; } function requestCount( state: CodingConversationStoreState, conversationId: string | null, statuses: ReadonlySet, ): number { if (!conversationId) return 0; const requests = selectCodingConversationRequests(conversationId)(state); return Object.values(requests).filter((request) => statuses.has(request.status)).length; } const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']); const SUBMITTING_REQUEST_STATUSES = new Set(['pending']); const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']); const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']); function acceptedPromptCount( state: CodingConversationStoreState, conversationId: string | null, ): number { if (!conversationId) return 0; return Object.values(selectCodingConversationRequests(conversationId)(state)).filter((request) => ( request.status === 'accepted' && request.mode === 'prompt' )).length; } export function CodingChatPanel({ navigationDraft, onOpenProjectSettings, }: CodingChatPanelProps) { const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const config = useCodingWorkspaceStore((state) => state.config); const conversations = useCodingWorkspaceStore((state) => state.conversations); const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId); const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState); const workspaceError = useCodingWorkspaceStore((state) => state.error); const conversationErrorsByProjectId = useCodingWorkspaceStore( (state) => state.conversationErrorsByProjectId, ); const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds); const loadWorkspace = useCodingWorkspaceStore((state) => state.load); const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent); const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation); const createConversation = useCodingWorkspaceStore((state) => state.createConversation); const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation); const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation); const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); const connectionState = useCodingConversationStore((state) => state.connectionState); const connectionError = useCodingConversationStore((state) => state.globalError); const primeConversation = useCodingConversationStore((state) => state.primeConversation); const selectConversation = useCodingConversationStore((state) => state.selectConversation); const clearConversationSelection = useCodingConversationStore((state) => state.clearConversationSelection); const disconnectEvents = useCodingConversationStore((state) => state.disconnectEvents); const setConversationDraft = useCodingConversationStore((state) => state.setDraft); const submitPrompt = useCodingConversationStore((state) => state.submitPrompt); const recoverConversation = useCodingConversationStore((state) => state.recoverConversation); const loadConversationSnapshot = useCodingConversationStore((state) => state.loadSnapshot); const markConversationUnread = useCodingConversationStore((state) => state.markUnread); const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId); const [provisionalDrafts, setProvisionalDrafts] = useState>({}); const [submissionErrors, setSubmissionErrors] = useState>({}); const [modesByDraftKey, setModesByDraftKey] = useState>({}); const [inspectorOpen, setInspectorOpen] = useState(false); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const attachmentsRef = useRef(attachmentsByDraftKey); const uploadedAttachmentsRef = useRef(new Map()); const uploadFlightsRef = useRef(new Map>()); const submissionFlightsRef = useRef(new Set()); const agents = useMemo(() => ( config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] ), [config]); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const agentConversations = useMemo(() => ( selectedAgent ? conversations .filter((conversation) => conversation.agentId === selectedAgent.id && !conversation.archivedAt) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) : [] ), [conversations, selectedAgent]); const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId && conversation.agentId === selectedAgent?.id && !conversation.archivedAt )) ?? null; const targetConversationId = selectedConversation?.id ?? null; const conversationMetadataError = activeProject && targetConversationId ? conversationErrorsByProjectId[activeProject.id]?.[targetConversationId] ?? null : null; const provisionalDraftKey = activeProject && selectedAgent ? `new:${activeProject.id}:${selectedAgent.id}` : null; const draftKey = targetConversationId ?? provisionalDraftKey; const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( draftKey ? attachmentsByDraftKey[draftKey] ?? [] : [] ), [attachmentsByDraftKey, draftKey]); attachmentsRef.current = attachmentsByDraftKey; const selectDraft = useCallback((state: CodingConversationStoreState) => ( targetConversationId ? selectCodingConversationDraft(targetConversationId)(state) : null ), [targetConversationId]); const selectedDraft = useCodingConversationStore(selectDraft); const selectSnapshot = useCallback((state: CodingConversationStoreState) => ( targetConversationId ? selectCodingConversationSnapshot(targetConversationId)(state) : null ), [targetConversationId]); const snapshot = useCodingConversationStore(selectSnapshot); const entryLoadState = useCodingConversationStore((state) => ( targetConversationId ? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty' : 'empty' )); const entryError = useCodingConversationStore((state) => ( targetConversationId ? state.entriesByConversationId[targetConversationId]?.error ?? null : null )); const blockingRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, BLOCKING_REQUEST_STATUSES, )); const acceptedRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, ACCEPTED_REQUEST_STATUSES, )); const acceptedPromptRequestCount = useCodingConversationStore((state) => acceptedPromptCount( state, targetConversationId, )); const uncertainRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, UNCERTAIN_REQUEST_STATUSES, )); const submittingRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, SUBMITTING_REQUEST_STATUSES, )); useEffect(() => { void loadWorkspace().catch(() => undefined); }, [loadWorkspace]); useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => () => { for (const attachments of Object.values(attachmentsRef.current)) { for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl); } }, []); useEffect(() => { if (!activeProject) return; for (const conversation of conversations) { primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); } }, [activeProject, conversations, primeConversation]); useEffect(() => { if (!activeProject || !selectedAgent) { clearConversationSelection(); return; } if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return; const next = newestConversation(conversations, selectedAgent.id); if (next) { markConversationUnread(next.id, false); if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined); void selectConversation(next.id).catch(() => undefined); return; } const creationKey = `${activeProject.id}:${selectedAgent.id}`; if (automaticCreationKeyRef.current === creationKey) return; automaticCreationKeyRef.current = creationKey; void ensureConversation(selectedAgent.id) .then((conversation) => { primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === activeProject.id && current.selectedAgentId === selectedAgent.id) { void selectConversation(conversation.id).catch(() => undefined); } }) .catch(() => undefined); }, [ activeProject, clearConversationSelection, conversations, ensureConversation, markConversationUnread, patchConversation, primeConversation, selectConversation, selectedAgent, selectedConversation, ]); useEffect(() => { const nextDraft = navigationDraft?.trim(); if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return; appliedNavigationDraftRef.current = nextDraft; if (targetConversationId) { setConversationDraft(targetConversationId, nextDraft); } else if (provisionalDraftKey) { setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: nextDraft })); } else { return; } }, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); useEffect(() => { if (!targetConversationId) return; if (provisionalDraft.trim()) { const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]; if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft); if (provisionalDraftKey) { setProvisionalDrafts((drafts) => { const next = { ...drafts }; delete next[provisionalDraftKey]; return next; }); } } if (provisionalDraftKey) { setAttachmentsByDraftKey((currentAttachments) => { const pending = currentAttachments[provisionalDraftKey]; if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments; const next = { ...currentAttachments, [targetConversationId]: pending }; delete next[provisionalDraftKey]; return next; }); } }, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => { if (!activeProject) return; primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); markConversationUnread(conversation.id, false); if (conversation.unread) { void patchConversation(conversation.id, { unread: false }).catch(() => undefined); } void selectConversation(conversation.id).catch(() => undefined); }, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]); const handleCreateConversation = useCallback(async () => { if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return; const projectId = activeProject.id; const agentId = selectedAgent.id; const conversation = await createConversation(agentId).catch(() => null); if (!conversation) return; primeConversation(createLocalConversationSnapshot(projectId, conversation)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === projectId && current.selectedAgentId === agentId) { await selectConversation(conversation.id).catch(() => undefined); } }, [ activeProject, createConversation, creatingAgentIds, primeConversation, selectConversation, selectedAgent, ]); const handleForkConversation = useCallback(async (sourceEntryId?: string) => { if (!activeProject || !selectedAgent || !targetConversationId) return; const sourceProjectId = activeProject.id; const sourceAgentId = selectedAgent.id; const sourceConversationId = targetConversationId; const forked = await forkCodingConversation(sourceConversationId, sourceEntryId); const workspace = codingWorkspaceStore.getState(); if (workspace.activeProjectId !== sourceProjectId) return; upsertConversation(sourceProjectId, forked); primeConversation(createLocalConversationSnapshot(sourceProjectId, forked)); const selectedId = codingConversationStore.getState().selectedConversationId; if (workspace.activeProjectId === sourceProjectId && workspace.selectedAgentId === sourceAgentId && selectedId === sourceConversationId) { await selectConversation(forked.id); } }, [activeProject, primeConversation, selectConversation, selectedAgent, targetConversationId, upsertConversation]); const handleAddFiles = useCallback((files: File[]) => { if (!draftKey || (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return; const accepted: LocalComposerAttachment[] = []; const remaining = Math.max(0, CODING_ATTACHMENT_MAX_COUNT - localAttachments.length); let validationError = files.length > remaining ? `每条消息最多添加 ${CODING_ATTACHMENT_MAX_COUNT} 张图片。` : null; for (const file of files.slice(0, remaining)) { const mime = file.type.trim().toLowerCase(); if (!CODING_ATTACHMENT_MIMES.has(mime)) { validationError = '仅支持 PNG、JPEG、WebP 或 GIF 图片。'; continue; } if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) { validationError = '图片不能超过 16 MB。'; continue; } accepted.push({ id: crypto.randomUUID(), name: file.name || '图片', file, previewUrl: URL.createObjectURL(file), }); } setSubmissionErrors((current) => { const next = { ...current }; if (validationError) next[draftKey] = validationError; else delete next[draftKey]; return next; }); if (accepted.length === 0) return; setAttachmentsByDraftKey((current) => ({ ...current, [draftKey]: [...(current[draftKey] ?? []), ...accepted], })); }, [draftKey, localAttachments.length, targetConversationId]); const handleRemoveAttachment = useCallback((id: string) => { if (!draftKey || (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return; setAttachmentsByDraftKey((current) => { const attachment = current[draftKey]?.find((item) => item.id === id); if (attachment) URL.revokeObjectURL(attachment.previewUrl); const nextItems = (current[draftKey] ?? []).filter((item) => item.id !== id); const next = { ...current, [draftKey]: nextItems }; if (nextItems.length === 0) delete next[draftKey]; return next; }); uploadedAttachmentsRef.current.delete(id); uploadFlightsRef.current.delete(id); setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); }, [draftKey, targetConversationId]); const prepareAttachments = useCallback(async ( attachments: LocalComposerAttachment[], ): Promise => { const prepared = new Array(attachments.length); let nextIndex = 0; const workers = Array.from({ length: Math.min(CODING_ATTACHMENT_UPLOAD_CONCURRENCY, attachments.length), }, async () => { while (nextIndex < attachments.length) { const index = nextIndex++; const attachment = attachments[index]; let uploaded = uploadedAttachmentsRef.current.get(attachment.id); if (!uploaded) { let flight = uploadFlightsRef.current.get(attachment.id); if (!flight) { flight = uploadCodingAttachment(attachment.file); uploadFlightsRef.current.set(attachment.id, flight); } try { uploaded = await flight; uploadedAttachmentsRef.current.set(attachment.id, uploaded); } finally { if (uploadFlightsRef.current.get(attachment.id) === flight) { uploadFlightsRef.current.delete(attachment.id); } } } prepared[index] = { attachmentId: uploaded.attachmentId, mime: uploaded.mime, previewUrl: attachment.previewUrl, }; } }); const results = await Promise.allSettled(workers); for (const result of results) { if (result.status === 'rejected') throw result.reason; } return prepared; }, []); const handleSubmit = useCallback(() => { if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return; const conversationId = targetConversationId; const attachments = [...localAttachments]; submissionFlightsRef.current.add(conversationId); setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); void submitPrompt({ conversationId, mode: promptMode, prepareAttachments: attachments.length > 0 ? () => prepareAttachments(attachments) : undefined, }).then(() => { setAttachmentsByDraftKey((current) => { const next = { ...current }; delete next[draftKey]; return next; }); for (const attachment of attachments) { URL.revokeObjectURL(attachment.previewUrl); uploadedAttachmentsRef.current.delete(attachment.id); } }).catch((error) => { setSubmissionErrors((current) => ({ ...current, [draftKey]: error instanceof Error ? error.message : String(error), })); }).finally(() => { submissionFlightsRef.current.delete(conversationId); }); }, [ draftKey, localAttachments, prepareAttachments, promptMode, targetConversationId, submitPrompt, ]); const runStatus = snapshot?.run.status ?? 'idle'; const workerStatus = snapshot?.worker.status ?? 'stopped'; const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; const preparationError = entryError ?? runtimeError?.message ?? null; const preparing = entryLoadState === 'loading' || workerStatus === 'starting' || workerStatus === 'recovering' || runStatus === 'preparing'; const recovering = entryLoadState === 'recovering'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt'; const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => ( interaction.status === 'pending' )).length ?? 0; const draft = selectedDraft?.text ?? provisionalDraft; const editable = Boolean(activeProject && selectedAgent); const canSend = Boolean( editable && targetConversationId && (draft.trim() || localAttachments.length > 0) && modeMatchesRunState && submittingRequestCount === 0 && !preparationError && (running || acceptedPromptRequestCount === 0) && blockingRequestCount - acceptedRequestCount === 0 && uncertainRequestCount === 0 && pendingInteractionCount === 0, ); const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); if (!activeProject && workspaceLoadState !== 'loading') { return (

先选择一个编程项目

项目和伙伴信息只在本地读取,不会为了打开输入框启动 Agent。

); } return (
{ if (targetConversationId) await patchConversation(targetConversationId, { title }); }} onArchive={async () => { if (!targetConversationId) return; const sourceConversationId = targetConversationId; await patchConversation(sourceConversationId, { archived: true }); if (codingConversationStore.getState().selectedConversationId === sourceConversationId) { clearConversationSelection(); } }} onToggleUnread={async () => { if (!targetConversationId || !selectedConversation) return; const unread = !selectedConversation.unread; await patchConversation(targetConversationId, { unread }); markConversationUnread(targetConversationId, unread); }} onFork={() => handleForkConversation()} onRefresh={async () => { if (targetConversationId) await loadConversationSnapshot(targetConversationId, true); }} onRecover={async () => { if (targetConversationId) await recoverConversation(targetConversationId); }} onOpenInspector={() => setInspectorOpen(true)} onOpenSettings={onOpenProjectSettings} /> {(workspaceError || conversationMetadataError || connectionError) && (
)} {targetConversationId ? ( { void handleForkConversation(sourceEntryId).catch((error) => { if (!draftKey) return; setSubmissionErrors((current) => ({ ...current, [draftKey]: error instanceof Error ? error.message : String(error), })); }); }} /> ) : (
)} {targetConversationId && ( loadConversationSnapshot(targetConversationId, true).then(() => undefined)} /> )} ({ id, name, previewUrl }))} submitting={submittingRequestCount > 0} placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'} onModeChange={(mode) => { if (draftKey) setModesByDraftKey((current) => ({ ...current, [draftKey]: mode })); }} onChange={(value) => { if (draftKey) { setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); } if (targetConversationId) setConversationDraft(targetConversationId, value); else if (provisionalDraftKey) { setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: value })); } }} onSubmit={handleSubmit} onRecover={() => { if (targetConversationId) { void recoverConversation(targetConversationId).catch(() => undefined); } }} onAddFiles={handleAddFiles} onRemoveAttachment={handleRemoveAttachment} /> { if (!targetConversationId) return; const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]?.text ?? ''; setConversationDraft(targetConversationId, current ? `${current}\n${command}` : command); }} />
); }