feat(coding): add conversation archive and first-message titles

This commit is contained in:
brother7 committed 2026-09-20 12:43:51 +08:00
1 parent d61226532a
commit fd0293fe3d
22 files changed
+698 -69

No files matched your search

+83 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
Bot,
ChevronRight,
@@ -43,6 +44,7 @@ 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';
@@ -170,6 +172,12 @@ 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 appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
@@ -185,7 +193,7 @@ export function CodingChatPanel({
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
&& conversation.agentId === selectedAgent?.id
&& !conversation.archivedAt
&& (!conversation.archivedAt || showArchived)
)) ?? null;
const targetConversationId = selectedConversation?.id ?? null;
const conversationMetadataError = activeProject && targetConversationId
@@ -268,6 +276,8 @@ export function CodingChatPanel({
useEffect(() => {
setAgentBrowserOpen(false);
setArchivedContext(null);
setRenameTarget(null);
}, [activeProject?.id]);
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
@@ -309,7 +319,7 @@ export function CodingChatPanel({
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) {
if (selectedConversation?.agentId === selectedAgent.id) {
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
if (selectedConversationContextRef.current !== selectionContext) {
void selectProjectConversation(activeProject.id, selectedConversation.id)
@@ -317,6 +327,10 @@ export function CodingChatPanel({
}
return;
}
if (showArchived) {
clearConversationSelection();
return;
}
const next = newestConversation(conversations, selectedAgent.id);
if (next) {
markConversationUnread(next.id, false);
@@ -325,6 +339,11 @@ export function CodingChatPanel({
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)
@@ -349,6 +368,7 @@ export function CodingChatPanel({
selectProjectConversation,
selectedAgent,
selectedConversation,
showArchived,
]);
useEffect(() => {
@@ -407,6 +427,7 @@ export function CodingChatPanel({
primeConversation(createLocalConversationSnapshot(projectId, conversation));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
setArchivedContext(null);
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
}
}, [
@@ -418,6 +439,40 @@ export function CodingChatPanel({
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;
@@ -704,7 +759,13 @@ export function CodingChatPanel({
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);
}}
@@ -712,6 +773,12 @@ export function CodingChatPanel({
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
@@ -758,7 +825,7 @@ export function CodingChatPanel({
key={targetConversationId}
conversationId={targetConversationId}
assistantName={selectedAgent?.name}
onFork={(sourceEntryId) => {
onFork={selectedConversation?.archivedAt ? undefined : (sourceEntryId) => {
void handleForkConversation(sourceEntryId).catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
@@ -789,7 +856,11 @@ export function CodingChatPanel({
</div>
)
: (
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
<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>
)}
{targetConversationId && (
@@ -809,7 +880,13 @@ export function CodingChatPanel({
/>
)}
<CodingComposer
{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>
</div>
) : <CodingComposer
value={draft}
editable={editable}
canSend={canSend}
@@ -866,7 +943,7 @@ export function CodingChatPanel({
onRefreshRuntime={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent');
}}
/>
/>}
</div>
<AgentBrowserPanel