Merge permanent conversation deletion and simplified project configuration

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 12:41:37 +08:00
commit c77d8cf3a2
21 files changed
+1104 -326

No files matched your search

+7
View File
@@ -159,11 +159,13 @@ export function CodingChatPanel({
(state) => state.conversationErrorsByProjectId,
);
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
const deletingConversationProjectIds = useCodingWorkspaceStore((state) => state.deletingConversationProjectIds);
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 selectionClearedByDeletionProjectId = useCodingConversationStore((state) => state.selectionClearedByDeletionProjectId);
const connectionError = useCodingConversationStore((state) => state.globalError);
const primeConversation = useCodingConversationStore((state) => state.primeConversation);
const selectConversation = useCodingConversationStore((state) => state.selectConversation);
@@ -376,6 +378,8 @@ export function CodingChatPanel({
}
return;
}
if (selectionClearedByDeletionProjectId === activeProject.id
|| (selectedConversationId && deletingConversationProjectIds[selectedConversationId] === activeProject.id)) return;
const next = newestConversation(conversations);
if (next) {
markConversationUnread(next.id, false);
@@ -386,6 +390,9 @@ export function CodingChatPanel({
clearConversationSelection();
}, [
activeProject,
selectionClearedByDeletionProjectId,
selectedConversationId,
deletingConversationProjectIds,
clearConversationSelection,
conversations,
markConversationUnread,
+22 -76
View File
@@ -1,13 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Archive,
ArchiveRestore,
LoaderCircle,
Plus,
MoreHorizontal,
Pencil,
Settings2,
Trash2,
MessageSquare,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
@@ -21,13 +19,11 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export function CodingProjectConversations({
projectId,
compact = false,
archived = false,
onArchivedChange,
onMenuOpenChange,
}: {
projectId: string;
compact?: boolean;
archived?: boolean;
onArchivedChange?: (archived: boolean) => void;
onMenuOpenChange?: (open: boolean) => void;
}) {
const navigate = useNavigate();
const conversations = useCodingWorkspaceStore((state) => state.conversations);
@@ -36,18 +32,6 @@ export function CodingProjectConversations({
const summaries = useCodingConversationStore((state) => state.summariesByConversationId);
const [busy, setBusy] = useState<string | null>(null);
const [rename, setRename] = useState<CodingConversationMetadata | null>(null);
const selectedConversation = conversations.find((item) => item.id === selected);
const previousSelection = useRef(selectedConversation);
useEffect(() => {
const previous = previousSelection.current;
if (
previous?.id === selectedConversation?.id &&
previous?.archivedAt &&
!selectedConversation?.archivedAt
)
onArchivedChange?.(false);
previousSelection.current = selectedConversation;
}, [selectedConversation, onArchivedChange]);
if (activeProjectId !== projectId) return null;
const act = async (key: string, operation: () => Promise<unknown>) => {
setBusy(key);
@@ -61,8 +45,12 @@ export function CodingProjectConversations({
};
const select = async (conversation: CodingConversationMetadata) => {
if (codingWorkspaceStore.getState().activeProjectId !== projectId) return;
const opened = conversation.archivedAt
? await codingWorkspaceStore.getState().patchConversation(conversation.id, { archived: false })
: conversation;
if (codingWorkspaceStore.getState().activeProjectId !== projectId) return;
const state = codingConversationStore.getState();
state.primeConversation(createLocalConversationSnapshot(projectId, conversation));
state.primeConversation(createLocalConversationSnapshot(projectId, opened));
state.markUnread(conversation.id, false);
if (conversation.unread)
void codingWorkspaceStore
@@ -91,7 +79,6 @@ export function CodingProjectConversations({
const workspace = codingWorkspaceStore.getState();
const conversation = await workspace.createConversation();
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
onArchivedChange?.(false);
await select(conversation);
}
})
@@ -101,25 +88,9 @@ export function CodingProjectConversations({
新对话
</button>
</div>
{archived && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1 text-xs text-muted-foreground">
<span>已归档对话</span>
<button
type="button"
onClick={() => onArchivedChange?.(false)}
className="rounded py-1 text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
>
返回最近对话
</button>
</div>
)}
{conversations
.filter((item) => Boolean(item.archivedAt) === archived)
.sort((a, b) =>
(archived ? b.archivedAt! : b.updatedAt).localeCompare(
archived ? a.archivedAt! : a.updatedAt
)
)
.slice()
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map((item) => {
const summary = summaries[item.id];
const running =
@@ -146,11 +117,11 @@ export function CodingProjectConversations({
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-label="未读" />
)}
</button>
<DropdownMenu.Root>
<DropdownMenu.Root onOpenChange={onMenuOpenChange}>
<DropdownMenu.Trigger asChild>
<button
aria-label={`对话操作:${item.title}`}
disabled={busy === item.id}
disabled={!!busy}
className="rounded p-1 text-muted-foreground opacity-0 focus-visible:opacity-100 group-hover/session:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
@@ -158,18 +129,9 @@ export function CodingProjectConversations({
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-[100] min-w-40 rounded-lg border bg-popover p-1 text-sm shadow-md"
className="z-[400] min-w-40 rounded-lg border bg-popover p-1 text-sm shadow-md"
align="end"
>
<DropdownMenu.Item
className="flex items-center rounded p-2 outline-none focus:bg-accent"
onSelect={() =>
navigate('/project-config?agent=' + encodeURIComponent(item.agentId))
}
>
<Settings2 className="mr-2 h-3.5 w-3.5" />
会话编程设置
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center rounded p-2 outline-none focus:bg-accent"
onSelect={() => setRename(item)}
@@ -178,35 +140,19 @@ export function CodingProjectConversations({
重命名
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center rounded p-2 outline-none focus:bg-accent"
className="flex items-center rounded p-2 text-destructive outline-none focus:bg-accent"
onSelect={() =>
void act(item.id, async () => {
await codingWorkspaceStore
.getState()
.patchConversation(item.id, { archived: !item.archivedAt });
if (
item.archivedAt &&
codingWorkspaceStore.getState().activeProjectId === projectId
)
onArchivedChange?.(false);
if (
!item.archivedAt &&
codingConversationStore.getState().selectedConversationId === item.id
)
codingConversationStore.getState().clearConversationSelection();
if (codingWorkspaceStore.getState().activeProjectId !== projectId)
throw new Error('项目已切换');
await codingWorkspaceStore.getState().removeConversation(item.id, () => {
codingConversationStore.getState().forgetConversation(item.id);
});
})
}
>
{item.archivedAt ? (
<ArchiveRestore className="mr-2 h-3.5 w-3.5" />
) : (
<Archive className="mr-2 h-3.5 w-3.5" />
)}
{item.archivedAt
? '恢复到最近对话'
: running
? '归档(任务继续运行)'
: '归档'}
<Trash2 aria-hidden="true" className="mr-2 h-3.5 w-3.5" />
删除对话
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
+3 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Check, Cpu, Files, Puzzle, Trash2, Upload, X } from 'lucide-react';
import { toast } from 'sonner';
@@ -10,7 +10,6 @@ import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { Plugins } from '@/pages/Plugins';
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
@@ -18,13 +17,6 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro
const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | null;
type SkillInfo = {
id: string;
name: string;
description: string;
available: boolean;
effective: boolean;
};
const THINKING_LEVEL_LABELS = {
off: '关闭',
@@ -122,24 +114,11 @@ export function ProjectConfiguration() {
const [draft, setDraft] = useState<CodingProjectConfig | null>(null);
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const location = useLocation();
const loadSkills = useCallback(() => {
void getCodingSkills()
.then((result) => setSkills(result.skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
available: skill.available,
effective: skill.effective,
}))))
.catch(() => setSkills([]));
}, []);
useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]);
useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]);
useEffect(() => {
@@ -147,7 +126,6 @@ export function ProjectConfiguration() {
if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return;
setActiveAgentId(requestedAgentId);
}, [draft, location.search]);
useEffect(() => { loadSkills(); }, [loadSkills]);
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
const modelOptions = useMemo(
() => buildCodingModelOptions(providerAccounts, providerVendors),
@@ -162,11 +140,10 @@ export function ProjectConfiguration() {
search: '?scope=all&source=all&state=available',
});
const closePluginWorkspace = () => {
loadSkills();
navigate('/project-config', { replace: true });
};
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、提示词、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
@@ -202,19 +179,12 @@ export function ProjectConfiguration() {
<section data-testid="project-resource-row" className="grid grid-cols-3 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理云端与本机能力" onClick={openPluginWorkspace} /></section>
{activeAgent && <section className="surface-card rounded-2xl border border-border/70 p-5" data-testid="project-default-settings">
<h2 className="text-lg font-semibold">{activeAgent.id === draft.defaultAgentId ? '默认编程配置' : '历史会话编程配置'}</h2>
<p className="mt-1 text-sm text-muted-foreground">新会话使用项目默认配置;历史会话保留原来的配置与记录。</p>
<p className="mt-1 text-sm text-muted-foreground">默认模型用于新对话;已有对话的模型和消息记录保持不变。</p>
<label className="mt-4 block text-sm">默认模型<select className="mt-2 block w-full rounded-lg border bg-background p-2"
value={activeAgent.model ? JSON.stringify([activeAgent.model.accountId, activeAgent.model.modelId]) : ''}
onChange={event => { const model = parseCodingModelKey(event.target.value); updateAgent({...activeAgent, model: model ? {...model, thinkingLevel: 'off', reasoningChoice: {mode: 'default'}} : null, modelResolution: model ? 'resolved' : 'required'}); }}>
<option value="">发送前选择模型</option>{modelOptions.map(model => <option key={model.key} value={model.key}>{model.modelId}</option>)}
</select></label>
<label className="mt-4 block text-sm">项目提示词<textarea className="mt-2 min-h-36 w-full rounded-lg border bg-background p-3" value={activeAgent.prompt} onChange={event => updateAgent({...activeAgent,prompt:event.target.value})} /></label>
<h3 className="mt-4 text-sm font-medium">项目 Skills</h3>
<div className="mt-2 space-y-2">{skills.map(skill => <label key={skill.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={activeAgent.skillIds.includes(skill.id)} disabled={!skill.available}
onChange={event => updateAgent({...activeAgent,skillIds:event.target.checked ? [...activeAgent.skillIds,skill.id] : activeAgent.skillIds.filter(id=>id!==skill.id)})} />
<span>{skill.name}<span className="block text-xs text-muted-foreground">{skill.description}</span></span>
</label>)}</div>
</section>}
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>