Files
makelore/src/pages/Chat/CodingChatPanel.tsx

1010 lines
48 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, 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<string>,
): 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<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record<string, LocalComposerAttachment[]>
>({});
const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
const [workRevision, setWorkRevision] = useState(0);
const [readyWorkProjectId, setReadyWorkProjectId] = useState<string | null>(null);
const recoveryFlight = useRef(false);
const appliedRecovery = useRef<string | null>(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 operationChatRef = useRef<HTMLDivElement>(null);
const [teacherQuote, setTeacherQuote] = useState<{
reference: TeacherReference; account: string; projectId: string; sourceId: string; agentId: string;
}>();
const consumeTeacherQuote = useCallback((reference: TeacherReference) => {
setTeacherQuote(current => current?.reference === reference ? undefined : current);
}, []);
const appliedNavigationDraftRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
const submissionFlightsRef = useRef(new Set<string>());
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<HTMLDivElement | null>(null);
const [teacherIntroduction, setTeacherIntroduction] = useState<HTMLDivElement | null>(null);
const openTeacher = (agentId?: string) => {
if (agentId) teacherCompanion.selectAgent(agentId);
const selection = window.getSelection();
const operationChat = operationChatRef.current;
// Avatar navigation must not turn selected text in another teacher's chat into a quote.
const fromOperationChat = operationChat && !operationChat.hidden && selection?.rangeCount
&& Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index))
.every(range => operationChat.contains(range.commonAncestorContainer));
const selectedText = fromOperationChat ? selection.toString().trim() : '';
setTeacherQuote(selectedText && activeProject && targetConversationId ? {
reference: { kind: 'code', text: selectedText.slice(0, 12000) }, account,
projectId: activeProject.id, sourceId: targetConversationId,
agentId: agentId ?? teacherCompanion.selectedAgent?.teacher_id ?? 'legacy',
} : undefined);
setConsultationOpen(true);
setAgentBrowserOpen(true);
};
const closeTeacher = () => {
setConsultationOpen(false);
const entry = document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"]')
?? document.querySelector<HTMLButtonElement>('.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); }, [account, 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<CodingDraftAttachment[]> => {
const prepared = new Array<CodingDraftAttachment>(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 (
<section
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background px-4 pb-3 pt-4 text-foreground sm:px-6 sm:pb-5"
data-testid="coding-chat-empty-project"
>
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
<div className="mx-auto w-full max-w-[52rem] shrink-0 pb-1">
{workspaceError ? (
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 text-pretty" role="alert">{workspaceError}</p>
<Button
type="button"
variant="ghost"
className="min-h-8 shrink-0 rounded-lg px-2.5 text-destructive"
onClick={() => void loadWorkspace().catch(() => undefined)}
>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
刷新
</Button>
</div>
) : null}
<Button
type="button"
data-testid="coding-start-project-button"
className="mx-auto flex h-12 min-w-44 rounded-xl border border-brand/20 bg-brand px-7 text-[15px] font-semibold text-primary-foreground shadow-[0_10px_24px_rgba(190,68,24,0.18)] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-brand-hover hover:shadow-[0_14px_30px_rgba(190,68,24,0.22)] active:translate-y-0"
onClick={onCreateProject}
disabled={!onCreateProject}
>
<Plus className="mr-2 h-4.5 w-4.5" aria-hidden="true" />
新增项目
</Button>
{projects.length > 0 ? (
<section className="mt-7" aria-label="项目列表">
<div
className="flex gap-3 overflow-x-auto pb-2"
data-testid="coding-existing-projects"
>
{projects.map((project) => (
<button
type="button"
key={project.id}
data-testid={`coding-existing-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
className="motion-press group/project-card flex min-h-[6.75rem] w-64 shrink-0 flex-col justify-between rounded-2xl bg-surface-subtle p-4 text-left shadow-[0_8px_22px_rgba(35,43,56,0.055)] ring-1 ring-foreground/[0.055] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-white hover:shadow-[0_14px_30px_rgba(35,43,56,0.09)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2"
onClick={() => onOpenProject?.(project.id)}
disabled={!onOpenProject}
>
<span className="flex items-start justify-between gap-4">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white text-brand shadow-[0_0_0_1px_rgba(35,43,56,0.06)] group-hover/project-card:bg-brand-soft">
<FolderKanban className="h-4 w-4" aria-hidden="true" />
</span>
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground/55 transition-[transform,color] duration-200 group-hover/project-card:translate-x-0.5 group-hover/project-card:text-foreground" aria-hidden="true" />
</span>
<span className="mt-4 min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">{project.name}</span>
<time
dateTime={project.lastOpenedAt}
className="mt-1 block text-xs text-muted-foreground"
>
{projectLastOpenedLabel(project)}
</time>
</span>
</button>
))}
</div>
</section>
) : null}
</div>
</section>
);
}
return (
<section className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel" data-consultation-open={consultationOpen}>
<div className="classroom-main flex min-w-0 flex-1 flex-col">
<CodingConversationHeader
key={`header:${targetConversationId ?? 'none'}`}
conversation={selectedConversation}
snapshot={snapshot}
onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title });
}}
onAbort={async () => {
if (targetConversationId) await abortConversation(targetConversationId);
}}
onRecover={async () => {
if (targetConversationId) await handleRecovery(codingRecovery(runtimeError?.code).action, false);
}}
consultationAvailable={Boolean(activeProject)}
onToggleConsultation={() => setConsultationOpen((current) => !current)}
projectName={activeProject?.name}
consultationOpen={consultationOpen}
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
/>
<div className="classroom-tabs" role="tablist" aria-label="当前工作">
<button role="tab" id="classroom-chat-tab" aria-controls="classroom-chat-view" aria-selected={!agentBrowserOpen} tabIndex={!agentBrowserOpen ? 0 : -1} onClick={() => setAgentBrowserOpen(false)} onKeyDown={(event) => { if (event.key === 'ArrowRight') { setAgentBrowserOpen(true); document.getElementById('classroom-work-tab')?.focus(); } }}><MessageSquare className="h-3.5 w-3.5" />操作对话</button>
<button role="tab" id="classroom-work-tab" aria-controls="classroom-work-view" aria-selected={agentBrowserOpen} tabIndex={agentBrowserOpen ? 0 : -1} onClick={() => setAgentBrowserOpen(true)} onKeyDown={(event) => { if (event.key === 'ArrowLeft') { setAgentBrowserOpen(false); document.getElementById('classroom-chat-tab')?.focus(); } }}><Play className="h-3.5 w-3.5" />作品</button>
</div>
<div id="classroom-work-view" role="tabpanel" aria-labelledby="classroom-work-tab" hidden={!agentBrowserOpen} className="classroom-work-view min-h-0 flex-1 flex-col p-5">
<AgentBrowserPanel projectId={activeProject?.id ?? null} open={agentBrowserOpen} onOpenChange={setAgentBrowserOpen} embedded
teacherBubble={teacherBubble}
teacherIntroduction={teacherIntroduction}
workRevision={workRevision}
onRecovery={handleRecovery}
onWorkResult={(result) => setReadyWorkProjectId(result.status === 'ready' ? activeProject?.id ?? null : null)}
conversationId={targetConversationId}
onWorkConversation={(conversation) => {
if (!activeProject || codingWorkspaceStore.getState().activeProjectId !== activeProject.id) return;
upsertConversation(activeProject.id, conversation);
if (!targetConversationId && !codingConversationStore.getState().selectedConversationId) {
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
if (draftKey) {
setConversationDraft(conversation.id, provisionalDraft);
setProvisionalDrafts((current) => { const next = { ...current }; delete next[draftKey]; return next; });
setAttachmentsByDraftKey((current) => {
const next = { ...current, [conversation.id]: current[draftKey] ?? [] };
delete next[draftKey]; return next;
});
}
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
}
}} />
</div>
<CodingChatBody welcome={showWelcome} hidden={agentBrowserOpen} bubble={teacherBubble}>
<div ref={operationChatRef} id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
{(workspaceError || conversationMetadataError || connectionError) && (
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 text-pretty">
{workspaceError ?? conversationMetadataError ?? connectionError}
</p>
{workspaceError && (
<Button
type="button"
variant="ghost"
className="min-h-10 rounded-xl px-3 text-destructive transition-transform duration-150 ease-out active:scale-[0.96]"
onClick={() => void loadWorkspace().catch(() => undefined)}
>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
刷新
</Button>
)}
</div>
)}
{showWelcome ? <CodingStartHeading /> : targetConversationId
? (
<CodingConversationTimeline
key={targetConversationId}
conversationId={targetConversationId}
assistantName={selectedAgent?.name}
onRecovery={handleRecovery}
workReady={readyWorkProjectId === activeProject?.id}
onFork={selectedConversation?.archivedAt ? undefined : (sourceEntryId) => {
void handleForkConversation(sourceEntryId).catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
});
}}
/>
)
: agents.length === 0
? (
<div className="flex min-h-0 flex-1 items-center justify-center p-6" data-testid="coding-chat-empty-agent">
<div className="max-w-sm text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</div>
<h1 className="mt-4 text-balance text-lg font-semibold">项目已准备好</h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
请打开项目设置检查默认编程配置。
</p>
{onOpenProjectSettings ? (
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
项目设置
</Button>
) : null}
</div>
</div>
)
: (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground" data-testid="coding-conversation-pending">
<div className="pb-12 text-center"><h1 className="text-2xl font-medium tracking-tight text-foreground">今天,想做点什么?</h1><p className="mt-4 text-sm leading-7">从一个小想法开始,<br />我们一起把它变成作品。</p></div>
</div>
)}
{targetConversationId && (
<CodingInteractionPanel
key={`interaction:${targetConversationId}`}
conversationId={targetConversationId}
interactions={snapshot?.pendingInteractions ?? []}
onSettled={() => loadConversationSnapshot(targetConversationId, 'silent').then(() => undefined)}
/>
)}
{targetConversationId && (
<CodingChangesSummary
key={`changes:${targetConversationId}`}
conversationId={targetConversationId}
snapshot={snapshot}
/>
)}
</div>
<div hidden={agentBrowserOpen} className="classroom-composer-view shrink-0">
{showWelcome && <CommunityInvitation />}
{selectedConversation?.archivedAt ? (
<div className="m-4 flex items-center justify-between gap-3 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
<span>{running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'}</span>
<Button onClick={() => void patchConversation(selectedConversation.id, { archived: false }).catch(() => undefined)}>恢复对话</Button>
</div>
) : selectedAgent && (!selectedAgent.enabled || selectedAgent.archivedAt) ? (
<div className="m-4 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
此历史会话的编程配置已停用,当前可查看历史与向智能体提问。
</div>
) : <CodingComposer
value={draft}
editable={editable}
canSend={canSend}
preparing={preparing || autoCreating}
recovering={recovering}
runStatus={runStatus}
mode={promptMode}
queue={snapshot?.queue ?? { items: [] }}
error={submissionError?.message ?? preparationError}
recoverableError={!submissionError && Boolean(preparationError)}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '说说你的想法,接下来想做什么?' : '项目编程配置尚未就绪'}
conversation={selectedConversation}
snapshot={snapshot}
voiceScopeKey={draftKey}
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}
onAbort={() => {
if (!targetConversationId) return;
const conversationId = targetConversationId;
void abortConversation(conversationId)
.catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
});
}}
onRecover={() => {
if (targetConversationId) {
void handleRecovery(codingRecovery(runtimeError?.code).action, false).catch(() => undefined);
}
}}
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
onRefreshRuntime={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent');
}}
/>}
</div>
</CodingChatBody>
</div>
{consultationOpen && activeProject && (
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
onKeyDown={(event) => { if (event.key === 'Escape') closeTeacher(); }}>
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
onPointerDown={(event) => { 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)))); } }} />
<TeacherChatPanel key={`${account}:${activeProject.id}:${teacherCompanion.selectedAgent?.teacher_id ?? 'legacy'}`}
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
selectedAgent={teacherCompanion.selectedAgent}
catalogState={teacherCompanion.catalogState}
reloadCatalog={teacherCompanion.reloadCatalog}
externalTopic={teacherCompanion.topic}
onTopicChange={teacherCompanion.noteTopic}
onComposingChange={setTeacherComposing}
quote={consultationOpen && teacherQuote?.account === account && teacherQuote.projectId === activeProject.id
&& teacherQuote.sourceId === targetConversationId
&& teacherQuote.agentId === (teacherCompanion.selectedAgent?.teacher_id ?? 'legacy') ? teacherQuote.reference : undefined}
onQuoteConsumed={consumeTeacherQuote} />
</div>
)}
</section>
);
}