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

This commit is contained in:
2026-09-20 12:43:51 +08:00
parent d61226532a
commit fd0293fe3d
22 changed files with 698 additions and 69 deletions

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

View File

@@ -9,15 +9,7 @@ import {
Square,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { TITLEBAR_LOGO_WIDTH, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from '@/components/layout/titlebar-metrics';
@@ -75,7 +67,6 @@ export function CodingConversationHeader({
const [busyAction, setBusyAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<LocalActionError | null>(null);
const [renameOpen, setRenameOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
const runStatus = snapshot?.run.status ?? 'preparing';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
@@ -122,7 +113,6 @@ export function CodingConversationHeader({
className="no-drag group flex max-w-full items-center gap-1.5 rounded-md text-left"
disabled={!conversation}
onClick={() => {
setTitleDraft(conversation?.title ?? '');
setRenameOpen(true);
}}
>
@@ -204,27 +194,8 @@ export function CodingConversationHeader({
</p>
)}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Input value={titleDraft} maxLength={160} aria-label="对话标题" onChange={(event) => setTitleDraft(event.target.value)} />
<DialogFooter>
<Button type="button" variant="outline" className="min-h-10 rounded-xl" onClick={() => setRenameOpen(false)}></Button>
<Button
type="button"
className="min-h-10 rounded-xl"
disabled={!titleDraft.trim() || Boolean(busyAction)}
onClick={() => perform('rename', async () => {
await onRename(titleDraft.trim());
setRenameOpen(false);
})}
></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{renameOpen && conversation && <CodingConversationRenameDialog
title={conversation.title} onSave={onRename} onClose={() => setRenameOpen(false)} />}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
export function CodingConversationRenameDialog({ title, onSave, onClose }: {
title: string;
onSave(title: string): Promise<void>;
onClose(): void;
}) {
const [draft, setDraft] = useState(title);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<Dialog open onOpenChange={(open) => { if (!open && !saving) onClose(); }}>
<DialogContent onOpenAutoFocus={(event) => {
event.preventDefault();
const input = document.getElementById('coding-rename-title') as HTMLInputElement | null;
input?.focus();
input?.select();
}}>
<form onSubmit={(event) => {
event.preventDefault();
if (!draft.trim() || saving) return;
setSaving(true);
setError(null);
void onSave(draft.trim()).then(onClose).catch((reason) => {
setError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => setSaving(false));
}}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Input id="coding-rename-title" className="my-4" value={draft} maxLength={200}
aria-label="对话标题" disabled={saving} onChange={(event) => setDraft(event.target.value)} />
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={saving} onClick={onClose}></Button>
<Button type="submit" disabled={saving || !draft.trim()}>{saving ? '保存中…' : '保存'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,7 +5,12 @@ import {
MessageSquarePlus,
Pin,
Settings2,
Archive,
MoreHorizontal,
Pencil,
ArchiveRestore,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Button } from '@/components/ui/button';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { cn } from '@/lib/utils';
@@ -47,6 +52,11 @@ export function CodingConversationSidebar({
onSelectConversation,
onCreateConversation,
onOpenProjectSettings,
showArchived = false,
onToggleArchived,
onRenameConversation,
onArchiveConversation,
busyConversationIds = {},
}: {
projectName?: string;
agents: CodingProjectAgent[];
@@ -59,6 +69,11 @@ export function CodingConversationSidebar({
onSelectConversation(conversation: CodingConversationMetadata): void;
onCreateConversation(): void;
onOpenProjectSettings?(): void;
showArchived?: boolean;
onToggleArchived?(): void;
onRenameConversation?(conversation: CodingConversationMetadata): void;
onArchiveConversation?(conversation: CodingConversationMetadata): void;
busyConversationIds?: Record<string, boolean>;
}) {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const platform = typeof window === 'undefined' ? undefined : window.electron?.platform;
@@ -113,13 +128,16 @@ export function CodingConversationSidebar({
<div className="min-h-0 flex-1 overflow-y-auto" data-testid="coding-conversation-sidebar-content">
{agents.map((agent) => {
const selected = agent.id === selectedAgentId;
const agentConversations = conversations
.filter((conversation) => conversation.agentId === agent.id && !conversation.archivedAt)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const unreadCount = agentConversations.filter((conversation) => (
const allAgentConversations = conversations.filter((conversation) => conversation.agentId === agent.id);
const archivedCount = allAgentConversations.filter((conversation) => conversation.archivedAt).length;
const agentConversations = allAgentConversations
.filter((conversation) => Boolean(conversation.archivedAt) === showArchived)
.sort((left, right) => (showArchived ? right.archivedAt! : right.updatedAt)
.localeCompare(showArchived ? left.archivedAt! : left.updatedAt));
const unreadCount = allAgentConversations.filter((conversation) => (
conversationSummaries[conversation.id]?.unread ?? conversation.unread
)).length;
const running = agentConversations.some((conversation) => {
const running = allAgentConversations.some((conversation) => {
const runStatus = conversationSummaries[conversation.id]?.runStatus;
return runStatus ? ACTIVE_RUN_STATUSES.has(runStatus) : false;
});
@@ -187,8 +205,15 @@ export function CodingConversationSidebar({
>
<div className="flex h-8 items-center justify-between px-1.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{showArchived ? '已归档' : '最近对话'}
</p>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" className="h-7 gap-1 px-1.5 text-[10px] text-muted-foreground"
aria-label={showArchived ? '返回最近对话' : `已归档(${archivedCount}`}
onClick={onToggleArchived}>
<Archive className="h-3 w-3" aria-hidden="true" />
{showArchived ? '返回最近' : archivedCount}
</Button>
<Button
type="button"
variant="ghost"
@@ -202,6 +227,7 @@ export function CodingConversationSidebar({
? <LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
: <MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />}
</Button>
</div>
</div>
<div className="space-y-0.5">
@@ -213,19 +239,19 @@ export function CodingConversationSidebar({
: false;
const conversationSelected = selectedConversationId === conversation.id;
return (
<button
<div
key={conversation.id}
type="button"
aria-label={conversation.title || '新对话'}
aria-current={conversationSelected ? 'page' : undefined}
className={cn(
'group/session flex min-h-9 w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left',
'group/session flex min-h-9 w-full items-center rounded-lg pr-1 text-left',
conversationSelected
? 'bg-background shadow-soft'
: 'text-muted-foreground hover:bg-background/75 hover:text-foreground',
)}
onClick={() => onSelectConversation(conversation)}
>
<button type="button" aria-label={conversation.title || '新对话'}
aria-current={conversationSelected ? 'page' : undefined}
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1.5 text-left"
onClick={() => onSelectConversation(conversation)}>
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold" title={conversation.title}>
{conversation.title || '新对话'}
</span>
@@ -234,13 +260,38 @@ export function CodingConversationSidebar({
)}
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-brand" aria-label="未读" />}
<span className="shrink-0 whitespace-nowrap text-[10px] text-muted-foreground">
{formatConversationTime(conversation.updatedAt) || '刚刚'}
{formatConversationTime(showArchived ? conversation.archivedAt! : conversation.updatedAt) || '刚刚'}
</span>
</button>
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" aria-label={`对话操作:${conversation.title}`}
disabled={busyConversationIds[conversation.id]}
className="flex h-7 w-6 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 hover:bg-muted focus-visible:opacity-100 group-hover/session:opacity-100 data-[state=open]:opacity-100">
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={4}
className="z-[100] min-w-40 rounded-lg border bg-popover p-1 text-sm text-popover-foreground shadow-md">
<DropdownMenu.Item className="flex cursor-default items-center rounded px-2 py-2 outline-none focus:bg-accent" onSelect={() => onRenameConversation?.(conversation)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
</DropdownMenu.Item>
<DropdownMenu.Item className="flex cursor-default items-center rounded px-2 py-2 outline-none focus:bg-accent" onSelect={() => onArchiveConversation?.(conversation)}>
{conversation.archivedAt ? <ArchiveRestore className="mr-2 h-3.5 w-3.5" /> : <Archive className="mr-2 h-3.5 w-3.5" />}
{conversation.archivedAt ? '恢复到最近对话' : conversationRunning ? '归档(任务继续运行)' : '归档'}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
);
})}
{agentConversations.length === 0 && (
{agentConversations.length === 0 && showArchived && (
<p className="px-2 py-5 text-center text-xs text-muted-foreground"></p>
)}
{agentConversations.length === 0 && !showArchived && (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 px-2.5 py-2.5 text-left text-xs text-muted-foreground hover:bg-background"
@@ -250,7 +301,7 @@ export function CodingConversationSidebar({
{creatingAgentIds[agent.id]
? <LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
: <MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />}
{creatingAgentIds[agent.id] ? '正在创建首个对话…' : '开始一条新对话'}
{creatingAgentIds[agent.id] ? '正在创建首个对话…' : '没有最近会话 · 新建对话'}
</button>
)}
</div>

View File

@@ -8,6 +8,7 @@ import {
} from '../../shared/coding-conversation-reducer';
import { AppError } from '@/lib/error-model';
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
import { codingWorkspaceStore } from './coding-workspace';
import {
getCodingConversationSnapshot,
openCodingConversationEvents,
@@ -660,8 +661,20 @@ export function createCodingConversationStore(
set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' });
}
});
source.addEventListener('conversation.metadata-changed', (event) => {
if (eventSource !== source) return;
try {
const metadata = parse<{ projectId: string }>(event);
if (typeof metadata.projectId === 'string') {
void codingWorkspaceStore.getState().refreshConversations(metadata.projectId).catch(() => undefined);
}
} catch { /* Reconnection refreshes authoritative metadata. */ }
});
source.onopen = () => {
if (eventSource === source) set({ connectionState: 'live', globalError: null });
if (eventSource !== source) return;
set({ connectionState: 'live', globalError: null });
const workspace = codingWorkspaceStore.getState();
if (workspace.activeProjectId) void workspace.refreshConversations(workspace.activeProjectId).catch(() => undefined);
};
source.onerror = () => {
if (eventSource === source) {

View File

@@ -61,6 +61,7 @@ export interface CodingWorkspaceState {
conversationErrorsByProjectId: Record<string, Record<string, string>>;
creatingAgentIds: Record<string, true>;
load(): Promise<void>;
refreshConversations(projectId: string): Promise<void>;
openProject(projectPath: string): Promise<CodingProjectSummary>;
createProject(input: {
projectPath?: string;
@@ -139,6 +140,9 @@ export function createCodingWorkspaceStore(
const conversationFlights = new Map<string, Promise<CodingConversationMetadata>>();
let loadFlight: Promise<void> | null = null;
let loadGeneration = 0;
let metadataRevision = 0;
let metadataTail = Promise.resolve();
const refreshFlights = new Map<string, { dirty: boolean; promise: Promise<void> }>();
return createStore<CodingWorkspaceState>((set, get) => ({
projects: [],
@@ -152,6 +156,30 @@ export function createCodingWorkspaceStore(
conversationErrorsByProjectId: {},
creatingAgentIds: {},
async refreshConversations(projectId) {
if (get().activeProjectId !== projectId) return;
const existing = refreshFlights.get(projectId);
if (existing) {
existing.dirty = true;
return await existing.promise;
}
const refresh = { dirty: true, promise: Promise.resolve() };
refresh.promise = (async () => {
while (refresh.dirty && get().activeProjectId === projectId) {
refresh.dirty = false;
await metadataTail;
const revision = metadataRevision;
const generation = loadGeneration;
const conversations = await deps.listConversations(projectId);
if (get().activeProjectId !== projectId || generation !== loadGeneration) return;
if (revision !== metadataRevision) { refresh.dirty = true; continue; }
if (!refresh.dirty) set({ conversations });
}
})().finally(() => { refreshFlights.delete(projectId); });
refreshFlights.set(projectId, refresh);
return await refresh.promise;
},
async load() {
if (loadFlight) return await loadFlight;
const generation = ++loadGeneration;
@@ -175,6 +203,8 @@ export function createCodingWorkspaceStore(
});
return;
}
if (get().activeProjectId === activeProject.id) await metadataTail;
const revision = metadataRevision;
const [snapshot, conversations] = await Promise.all([
deps.getConfig(activeProject.id),
deps.listConversations(activeProject.id),
@@ -186,11 +216,13 @@ export function createCodingWorkspaceStore(
activeProjectId: activeProject.id,
activeProject: snapshot.project,
config: snapshot.config,
conversations,
conversations: revision === metadataRevision || get().activeProjectId !== activeProject.id
? conversations : get().conversations,
selectedAgentId: currentAgentId ?? firstEnabledAgent(snapshot.config)?.id ?? null,
loadState: 'ready',
error: null,
});
if (revision !== metadataRevision) void get().refreshConversations(activeProject.id).catch(() => undefined);
})
.catch((error) => {
if (generation !== loadGeneration) return;
@@ -259,6 +291,7 @@ export function createCodingWorkspaceStore(
agentId,
title: '新对话',
}).then((conversation) => {
metadataRevision += 1;
if (get().activeProjectId !== state.activeProject?.id) return conversation;
set((current) => ({
conversations: [
@@ -285,6 +318,9 @@ export function createCodingWorkspaceStore(
async patchConversation(conversationId, patch) {
const sourceProjectId = get().activeProjectId;
if (!sourceProjectId) throw new Error('当前没有可用的项目。');
metadataRevision += 1;
const flight = metadataTail.then(() => deps.patchConversation(conversationId, patch));
metadataTail = flight.then(() => undefined, () => undefined);
set((current) => ({
conversationErrorsByProjectId: withConversationError(
current.conversationErrorsByProjectId,
@@ -294,10 +330,11 @@ export function createCodingWorkspaceStore(
),
}));
try {
const conversation = await deps.patchConversation(conversationId, patch);
const conversation = await flight;
get().upsertConversation(sourceProjectId, conversation);
return conversation;
} catch (error) {
metadataRevision += 1;
const message = error instanceof Error ? error.message : String(error);
set((current) => ({
conversationErrorsByProjectId: withConversationError(
@@ -312,6 +349,7 @@ export function createCodingWorkspaceStore(
},
upsertConversation(projectId, conversation) {
metadataRevision += 1;
if (get().activeProjectId !== projectId) return;
set((current) => ({
conversations: [