feat: organize project conversations and add coding teacher side chat

This commit is contained in:
brother7 committed 2026-09-22 12:30:26 +08:00
1 parent 2f82b9f79c
commit 9242a40d87
45 files changed
+2987 -1137

No files matched your search

+82 -176
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
Bot,
ChevronRight,
@@ -42,13 +41,13 @@ import type {
import { CodingComposer } from './CodingComposer';
import { AgentBrowserPanel } from './AgentBrowserPanel';
import { CodingChangesSummary } from './CodingChangesSummary';
import { CodingConversationSidebar } from './CodingConversationSidebar';
import { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
import { TeacherChatPanel } from './TeacherChatPanel';
import type { TeacherReference } from '../../../shared/coding-teacher';
export interface CodingChatPanelProps {
navigationDraft?: string;
@@ -85,10 +84,9 @@ function localSubmissionError(error: unknown): LocalSubmissionError {
function newestConversation(
conversations: CodingConversationMetadata[],
agentId: string,
): CodingConversationMetadata | null {
return conversations
.filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt)
.filter((conversation) => !conversation.archivedAt)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
}
@@ -146,9 +144,6 @@ export function CodingChatPanel({
);
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);
@@ -163,7 +158,6 @@ export function CodingChatPanel({
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<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
@@ -172,14 +166,10 @@ export function CodingChatPanel({
Record<string, LocalComposerAttachment[]>
>({});
const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
const [archivedContext, setArchivedContext] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<{ projectId: string; conversation: CodingConversationMetadata } | null>(null);
const [busyConversationIds, setBusyConversationIds] = useState<Record<string, boolean>>({});
const archiveFlights = useRef(new Set<string>());
const showArchived = Boolean(activeProject && selectedAgentId
&& archivedContext === `${activeProject.id}:${selectedAgentId}`);
const [teacherOpen, setTeacherOpen] = useState(false);
const [teacherQuote, setTeacherQuote] = useState<TeacherReference | undefined>();
const [teacherQuoteSource, setTeacherQuoteSource] = useState<string | null>(null);
const appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
@@ -189,12 +179,10 @@ export function CodingChatPanel({
const agents = useMemo(() => (
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
), [config]);
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
&& conversation.agentId === selectedAgent?.id
&& (!conversation.archivedAt || showArchived)
)) ?? 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
@@ -276,9 +264,8 @@ export function CodingChatPanel({
useEffect(() => {
setAgentBrowserOpen(false);
setArchivedContext(null);
setRenameTarget(null);
}, [activeProject?.id]);
useEffect(() => { setTeacherQuote(undefined); }, [activeProject?.id, targetConversationId]);
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
@@ -319,7 +306,7 @@ export function CodingChatPanel({
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id) {
if (selectedConversation) {
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
if (selectedConversationContextRef.current !== selectionContext) {
void selectProjectConversation(activeProject.id, selectedConversation.id)
@@ -327,48 +314,24 @@ export function CodingChatPanel({
}
return;
}
if (showArchived) {
clearConversationSelection();
return;
}
const next = newestConversation(conversations, selectedAgent.id);
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;
}
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
if (conversations.some((conversation) => conversation.agentId === selectedAgent.id)) {
selectedConversationContextRef.current = null;
clearConversationSelection();
return;
}
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 selectProjectConversation(activeProject.id, conversation.id)
.catch(() => undefined);
}
})
.catch(() => undefined);
clearConversationSelection();
}, [
activeProject,
clearConversationSelection,
conversations,
ensureConversation,
markConversationUnread,
patchConversation,
primeConversation,
selectProjectConversation,
selectedAgent,
selectedConversation,
showArchived,
]);
useEffect(() => {
@@ -384,95 +347,6 @@ export function CodingChatPanel({
}
}, [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 selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
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) {
setArchivedContext(null);
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
}
}, [
activeProject,
createConversation,
creatingAgentIds,
primeConversation,
selectProjectConversation,
selectedAgent,
]);
const handleArchiveConversation = async (conversation: CodingConversationMetadata, archived = !conversation.archivedAt) => {
if (!activeProject || archiveFlights.current.has(conversation.id)) return;
const projectId = activeProject.id;
const agentId = conversation.agentId;
const selectionAtStart = codingConversationStore.getState().selectedConversationId;
archiveFlights.current.add(conversation.id);
setBusyConversationIds((current) => ({ ...current, [conversation.id]: true }));
try {
const updated = await patchConversation(conversation.id, { archived });
const current = codingWorkspaceStore.getState();
if (current.activeProjectId !== projectId || current.selectedAgentId !== agentId) return;
if (!archived && codingConversationStore.getState().selectedConversationId === selectionAtStart) {
setArchivedContext(null);
handleSelectConversation(updated);
}
if (archived) {
const status = codingConversationStore.getState().summariesByConversationId[conversation.id]?.runStatus;
const active = status && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(status);
toast.success(active ? '对话已归档,任务继续运行' : '对话已归档', {
action: { label: '撤销', onClick: () => {
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
void handleArchiveConversation(updated, false);
}
} },
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '更新对话失败');
} finally {
archiveFlights.current.delete(conversation.id);
setBusyConversationIds((current) => { const next = { ...current }; delete next[conversation.id]; return next; });
}
};
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
if (!activeProject || !selectedAgent || !targetConversationId) return;
const sourceProjectId = activeProject.id;
@@ -591,6 +465,43 @@ export function CodingChatPanel({
}, []);
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];
@@ -625,6 +536,12 @@ export function CodingChatPanel({
submissionFlightsRef.current.delete(conversationId);
});
}, [
activeProject,
selectedAgent,
provisionalDraft,
primeConversation,
setConversationDraft,
selectProjectConversation,
draftKey,
localAttachments,
prepareAttachments,
@@ -657,10 +574,9 @@ export function CodingChatPanel({
interaction.status === 'pending'
)).length ?? 0;
const draft = selectedDraft?.text ?? provisionalDraft;
const editable = Boolean(activeProject && selectedAgent);
const editable = Boolean(activeProject && selectedAgent?.enabled && !selectedAgent.archivedAt);
const canSend = Boolean(
editable
&& targetConversationId
&& (draft.trim() || localAttachments.length > 0)
&& modeMatchesRunState
&& submittingRequestCount === 0
@@ -751,35 +667,6 @@ export function CodingChatPanel({
return (
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
<CodingConversationSidebar
projectName={activeProject?.name}
agents={agents}
conversations={conversations}
conversationSummaries={conversationSummaries}
selectedAgentId={selectedAgent?.id ?? null}
selectedConversationId={targetConversationId}
creatingAgentIds={creatingAgentIds}
showArchived={showArchived}
busyConversationIds={busyConversationIds}
onToggleArchived={() => setArchivedContext(showArchived ? null : `${activeProject?.id}:${selectedAgentId}`)}
onRenameConversation={(conversation) => { if (activeProject) setRenameTarget({ projectId: activeProject.id, conversation }); }}
onArchiveConversation={(conversation) => void handleArchiveConversation(conversation)}
onSelectAgent={(agentId) => {
setArchivedContext(null);
clearConversationSelection();
selectAgent(agentId);
}}
onSelectConversation={handleSelectConversation}
onCreateConversation={() => void handleCreateConversation()}
onOpenProjectSettings={onOpenProjectSettings}
/>
{renameTarget && renameTarget.projectId === activeProject?.id && (
<CodingConversationRenameDialog key={renameTarget.conversation.id}
title={renameTarget.conversation.title}
onSave={async (title) => { await patchConversation(renameTarget.conversation.id, { title }); }}
onClose={() => setRenameTarget(null)} />
)}
<div className="flex min-w-0 flex-1 flex-col">
<CodingConversationHeader
key={`header:${targetConversationId ?? 'none'}`}
@@ -797,6 +684,12 @@ export function CodingChatPanel({
browserOpen={agentBrowserOpen}
browserAvailable={Boolean(activeProject)}
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
onAskTeacher={() => {
const selectedText = window.getSelection()?.toString().trim();
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
setTeacherOpen(true);
}}
/>
{(workspaceError || conversationMetadataError || connectionError) && (
@@ -845,11 +738,11 @@ export function CodingChatPanel({
</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>
@@ -857,9 +750,7 @@ export function CodingChatPanel({
)
: (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground" data-testid="coding-conversation-pending">
{conversations.some((conversation) => conversation.agentId === selectedAgentId)
? showArchived ? '选择一条已归档会话查看' : '没有最近会话,可新建对话或查看归档'
: null}
开始输入,创建项目的第一条会话
</div>
)}
@@ -883,8 +774,11 @@ export function CodingChatPanel({
{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 disabled={busyConversationIds[selectedConversation.id]}
onClick={() => void handleArchiveConversation(selectedConversation, false)}>恢复对话</Button>
<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}
@@ -900,7 +794,7 @@ export function CodingChatPanel({
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '随心输入' : '请选择一个智能体后再发送'}
placeholder={selectedAgent ? '随心输入' : '项目编程配置尚未就绪'}
conversation={selectedConversation}
snapshot={snapshot}
voiceScopeKey={draftKey}
@@ -946,6 +840,18 @@ export function CodingChatPanel({
/>}
</div>
{teacherOpen && targetConversationId && activeProject && (
<div className="fixed inset-y-10 right-0 z-40 w-[min(400px,100vw)] shadow-xl xl:static xl:z-auto xl:w-[360px] xl:shrink-0 xl:shadow-none">
<TeacherChatPanel key={`${activeProject.id}:${targetConversationId}`}
projectId={activeProject.id} sourceId={targetConversationId}
quote={teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
onClose={() => setTeacherOpen(false)}
onBringBack={selectedConversation?.archivedAt ? undefined : (text) => {
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]?.text ?? '';
setConversationDraft(targetConversationId, current ? current + '\n\n' + text : text);
}} />
</div>
)}
<AgentBrowserPanel
projectId={activeProject?.id ?? null}
open={agentBrowserOpen}