import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bot, ChevronRight, CircleAlert, FolderKanban, Plus, RefreshCw, MessageSquare, Play, } 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 { abortCodingConversation, forkCodingConversation, } from '@/lib/coding-conversations'; import { subscribeHostEvent } from '@/lib/host-events'; 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, CodingProjectSummary, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; import { AgentBrowserPanel } from './AgentBrowserPanel'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; import { CodingWelcomeHero } from './CodingWelcomeHero'; import { CodingStartHeading } from './CodingStartHeading'; import { CodingChatBody } from './CodingChatBody'; import { CommunityInvitation } from '@/components/community/CommunityInvitation'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; import { useAuthStore } from '@/stores/auth'; import { TeacherChatPanel } from './TeacherChatPanel'; import { TeacherCompanion } from './TeacherCompanion'; import { useTeacherCompanion } from './use-teacher-companion'; import type { TeacherReference } from '../../../shared/coding-teacher'; import { codingRecovery, CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery'; import { codingRecoveryAccount, type CodingLoginRecovery } from '@/lib/coding-login-recovery'; import { openWorksBillingAccount } from '@/lib/works-billing'; export interface CodingChatPanelProps { navigationDraft?: string; onCreateProject?(): void; onOpenProject?(projectId: string): void; onOpenProjectSettings?(): void; onLoginRecovery?(projectId: string, conversationId: string | null, work: boolean): void; loginRecovery?: CodingLoginRecovery; onLoginRecoveryConsumed?(): void; } interface LocalComposerAttachment { id: string; name: string; file: File; previewUrl: string; } interface LocalSubmissionError { message: string; backendCode?: string; } function localSubmissionError(error: unknown): LocalSubmissionError { const message = error instanceof Error ? error.message : String(error); const details = error && typeof error === 'object' && 'details' in error && error.details && typeof error.details === 'object' ? error.details as { backendCode?: unknown } : null; return { message, ...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}), }; } function newestConversation( conversations: CodingConversationMetadata[], ): CodingConversationMetadata | null { return conversations .filter((conversation) => !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; } const PROJECT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', }); function projectLastOpenedLabel(project: CodingProjectSummary): string { const timestamp = Date.parse(project.lastOpenedAt); if (!Number.isFinite(timestamp)) return '本地项目'; return `最近打开 ${PROJECT_DATE_FORMATTER.format(timestamp)}`; } export function CodingChatPanel({ navigationDraft, onCreateProject, onOpenProject, onOpenProjectSettings, onLoginRecovery, loginRecovery, onLoginRecoveryConsumed, }: CodingChatPanelProps) { const projects = useCodingWorkspaceStore((state) => state.projects); 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 patchConversation = useCodingWorkspaceStore((state) => state.patchConversation); const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation); const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); 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 [provisionalDrafts, setProvisionalDrafts] = useState>({}); const [submissionErrors, setSubmissionErrors] = useState>({}); const [modesByDraftKey, setModesByDraftKey] = useState>({}); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); const [workRevision, setWorkRevision] = useState(0); const [readyWorkProjectId, setReadyWorkProjectId] = useState(null); const recoveryFlight = useRef(false); const appliedRecovery = useRef(null); const [consultationOpen, setConsultationOpen] = useState(false); const [teacherComposing, setTeacherComposing] = useState(false); const [consultationWidth, setConsultationWidth] = useState(508); const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out'); const [teacherQuote, setTeacherQuote] = useState(); const [teacherQuoteSource, setTeacherQuoteSource] = useState(null); const appliedNavigationDraftRef = useRef(null); const selectedConversationContextRef = 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 selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId )) ?? null; const selectedAgent = config?.agents.find((agent) => agent.id === (selectedConversation?.agentId ?? config.defaultAgentId ?? selectedAgentId)) ?? 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 abortConversation = useCallback(async (conversationId: string) => { await abortCodingConversation(conversationId); await loadConversationSnapshot(conversationId, 'silent'); }, [loadConversationSnapshot]); const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( draftKey ? attachmentsByDraftKey[draftKey] ?? [] : [] ), [attachmentsByDraftKey, draftKey]); attachmentsRef.current = attachmentsByDraftKey; const selectProjectConversation = useCallback((projectId: string, conversationId: string) => { selectedConversationContextRef.current = `${projectId}:${conversationId}`; return selectConversation(conversationId); }, [selectConversation]); 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 runStatus = snapshot?.run.status ?? 'idle'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing }); const [teacherBubble, setTeacherBubble] = useState(null); const [teacherIntroduction, setTeacherIntroduction] = useState(null); const openTeacher = (agentId?: string) => { if (agentId) teacherCompanion.selectAgent(agentId); const selectedText = window.getSelection()?.toString().trim(); setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined); setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`); setConsultationOpen(true); setAgentBrowserOpen(true); }; const closeTeacher = () => { setConsultationOpen(false); const entry = document.querySelector('.consultation-agent-tabs [aria-pressed="true"]') ?? document.querySelector('.consultation-agent-tabs button'); entry?.focus(); }; const toggleTeacher = (agentId?: string) => { if (consultationOpen && (!agentId || agentId === teacherCompanion.selectedAgent?.teacher_id)) { closeTeacher(); return; } openTeacher(agentId); }; const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; 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(() => { setAgentBrowserOpen(false); }, [activeProject?.id]); useEffect(() => { setTeacherQuote(undefined); }, [activeProject?.id, targetConversationId]); useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { disconnectEvents(); }), [disconnectEvents]); useEffect(() => { if (!targetConversationId) return undefined; const refreshVisibleConversation = () => { if (document.visibilityState !== 'visible') return; void selectConversation(targetConversationId).catch(() => undefined); }; window.addEventListener('focus', refreshVisibleConversation); document.addEventListener('visibilitychange', refreshVisibleConversation); return () => { window.removeEventListener('focus', refreshVisibleConversation); document.removeEventListener('visibilitychange', refreshVisibleConversation); }; }, [selectConversation, targetConversationId]); 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) { selectedConversationContextRef.current = null; clearConversationSelection(); return; } if (selectedConversation) { const selectionContext = `${activeProject.id}:${selectedConversation.id}`; if (selectedConversationContextRef.current !== selectionContext) { void selectProjectConversation(activeProject.id, selectedConversation.id) .catch(() => undefined); } return; } const next = newestConversation(conversations); if (next) { markConversationUnread(next.id, false); if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined); void selectProjectConversation(activeProject.id, next.id).catch(() => undefined); return; } clearConversationSelection(); }, [ activeProject, clearConversationSelection, conversations, markConversationUnread, patchConversation, primeConversation, selectProjectConversation, 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]); 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 selectProjectConversation(sourceProjectId, forked.id); } }, [activeProject, primeConversation, selectProjectConversation, 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] = { message: 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 && activeProject && selectedAgent && !submissionFlightsRef.current.has(draftKey)) { const projectId = activeProject.id; const initialText = provisionalDraft; const attachments = [...localAttachments]; const selectedAtSend = codingConversationStore.getState().selectedConversationId; let createdId: string | null = null; submissionFlightsRef.current.add(draftKey); void codingWorkspaceStore.getState().createConversation().then(async (conversation) => { createdId = conversation.id; primeConversation(createLocalConversationSnapshot(projectId, conversation)); setConversationDraft(conversation.id, initialText); setProvisionalDrafts(current => { if (current[draftKey] !== initialText) return current; const next = { ...current }; delete next[draftKey]; return next; }); setAttachmentsByDraftKey(current => { const next = { ...current, [conversation.id]: attachments }; delete next[draftKey]; return next; }); if (codingWorkspaceStore.getState().activeProjectId === projectId && codingConversationStore.getState().selectedConversationId === selectedAtSend) { void selectProjectConversation(projectId, conversation.id).catch(() => undefined); } await submitPrompt({ conversationId: conversation.id, mode: 'prompt', prepareAttachments: attachments.length ? () => prepareAttachments(attachments) : undefined }); setAttachmentsByDraftKey(current => { const next = { ...current }; delete next[conversation.id]; return next; }); for (const attachment of attachments) { URL.revokeObjectURL(attachment.previewUrl); uploadedAttachmentsRef.current.delete(attachment.id); } }).catch((error) => { setSubmissionErrors(current => ({ ...current, [createdId ?? draftKey]: localSubmissionError(error) })); }).finally(() => submissionFlightsRef.current.delete(draftKey)); return; } 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]: localSubmissionError(error), })); }).finally(() => { submissionFlightsRef.current.delete(conversationId); }); }, [ activeProject, selectedAgent, provisionalDraft, primeConversation, setConversationDraft, selectProjectConversation, draftKey, localAttachments, prepareAttachments, promptMode, targetConversationId, submitPrompt, ]); const workerStatus = snapshot?.worker.status ?? 'stopped'; const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; const preparationError = entryError ?? runtimeError?.message ?? null; useEffect(() => { if (!draftKey || uncertainRequestCount > 0 || runtimeError?.code === 'CODING_REQUEST_UNCERTAIN') return; setSubmissionErrors((current) => { if (current[draftKey]?.backendCode !== 'CODING_REQUEST_UNCERTAIN') return current; const next = { ...current }; delete next[draftKey]; return next; }); }, [draftKey, runtimeError?.code, uncertainRequestCount]); const preparing = entryLoadState === 'loading' || workerStatus === 'starting' || workerStatus === 'recovering' || runStatus === 'preparing'; const recovering = entryLoadState === 'recovering'; 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?.enabled && !selectedAgent.archivedAt); const canSend = Boolean( editable && (draft.trim() || localAttachments.length > 0) && modeMatchesRunState && submittingRequestCount === 0 && !preparationError && (running || acceptedPromptRequestCount === 0) && blockingRequestCount - acceptedRequestCount === 0 && uncertainRequestCount === 0 && pendingInteractionCount === 0, ); const handleRecovery = useCallback(async (action: CodingRecoveryAction, work: boolean) => { if (!activeProject || recoveryFlight.current) return; if (action === 'login') { onLoginRecovery?.(activeProject.id, targetConversationId, work); return; } if (action === 'settings') { onOpenProjectSettings?.(); return; } if (action === 'account') { await openWorksBillingAccount(); return; } if (action === 'check-work') { setWorkRevision((value) => value + 1); setAgentBrowserOpen(true); return; } if (!targetConversationId) return; recoveryFlight.current = true; try { if (action === 'refresh') { await loadConversationSnapshot(targetConversationId, 'silent'); return; } // Refresh before continuing: another window/run may already have made progress. await loadConversationSnapshot(targetConversationId, 'silent'); const current = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState()); if (!current || !['idle', 'error'].includes(current.run.status)) return; if (current.run.error?.code === 'CODING_REQUEST_UNCERTAIN') return; const requests = codingConversationStore.getState().requestsByConversationId[targetConversationId]; if (Object.values(requests ?? {}).some((request) => ['pending', 'uncertain'].includes(request.status))) return; if (current.run.terminalReason === 'completed') return; const recoveryAccount = codingRecoveryAccount(useAuthStore.getState().user); await recoverConversation(targetConversationId); if (codingWorkspaceStore.getState().activeProjectId !== activeProject.id || recoveryAccount !== codingRecoveryAccount(useAuthStore.getState().user)) return; const refreshed = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState()); if (!refreshed || !['idle', 'error'].includes(refreshed.run.status)) return; await submitPrompt({ conversationId: targetConversationId, mode: 'prompt', text: CONTINUE_CODING_PROMPT, attachments: [], preserveDraft: true }); } finally { recoveryFlight.current = false; } }, [activeProject, targetConversationId, onLoginRecovery, onOpenProjectSettings, loadConversationSnapshot, recoverConversation, submitPrompt]); useEffect(() => { if (!loginRecovery || appliedRecovery.current === loginRecovery.id || workspaceLoadState !== 'ready') return; if (loginRecovery.account !== codingRecoveryAccount(useAuthStore.getState().user) || loginRecovery.projectId !== activeProject?.id) { appliedRecovery.current = loginRecovery.id; onLoginRecoveryConsumed?.(); return; } if (loginRecovery.conversationId !== targetConversationId || (targetConversationId ? entryLoadState !== 'live' : workspaceLoadState !== 'ready')) return; appliedRecovery.current = loginRecovery.id; onLoginRecoveryConsumed?.(); void handleRecovery(loginRecovery.work ? 'check-work' : 'continue', loginRecovery.work).catch((error) => { if (draftKey) setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error) })); }); }, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]); const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); const showWelcome = editable && !selectedConversation?.archivedAt && !running && runStatus === 'idle' && (!targetConversationId || (entryLoadState === 'live' && snapshot?.nodes.length === 0)); if (!activeProject && workspaceLoadState !== 'loading') { return (
{workspaceError ? (
) : null} {projects.length > 0 ? (
{projects.map((project) => ( ))}
) : null}
); } return (
{ if (targetConversationId) await patchConversation(targetConversationId, { title }); }} onAbort={async () => { if (targetConversationId) await abortConversation(targetConversationId); }} onRecover={async () => { if (targetConversationId) await handleRecovery(codingRecovery(runtimeError?.code).action, false); }} browserOpen={agentBrowserOpen} browserAvailable={Boolean(activeProject)} onToggleBrowser={() => setAgentBrowserOpen((current) => !current)} projectName={activeProject?.name} consultationOpen={consultationOpen} teacherEntry={activeProject ? : undefined} />
{consultationOpen && activeProject && (
{ if (event.key === 'Escape') closeTeacher(); }}>
{ if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }} onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} />
)}
); }