feat(coding): add conversation archive and first-message titles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/pages/Chat/CodingConversationRenameDialog.tsx
Normal file
46
src/pages/Chat/CodingConversationRenameDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user