Files
makelore/src/pages/Chat/CodingProjectConversations.tsx

217 lines
9.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Archive,
ArchiveRestore,
LoaderCircle,
MessageSquarePlus,
MoreHorizontal,
Pencil,
Settings2,
MessageSquare,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { toast } from 'sonner';
import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { codingConversationStore, useCodingConversationStore } from '@/stores/coding-conversations';
import type { CodingConversationMetadata } from '@/types/coding-project';
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export function CodingProjectConversations({ projectId, compact = false }: { projectId: string; compact?: boolean }) {
const navigate = useNavigate();
const conversations = useCodingWorkspaceStore((state) => state.conversations);
const activeProjectId = useCodingWorkspaceStore((state) => state.activeProjectId);
const selected = useCodingConversationStore((state) => state.selectedConversationId);
const summaries = useCodingConversationStore((state) => state.summariesByConversationId);
const [archived, setArchived] = useState(false);
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
)
setArchived(false);
previousSelection.current = selectedConversation;
}, [selectedConversation]);
if (activeProjectId !== projectId) return null;
const act = async (key: string, operation: () => Promise<unknown>) => {
setBusy(key);
try {
await operation();
} catch (error) {
toast.error(error instanceof Error ? error.message : '操作失败');
} finally {
setBusy(null);
}
};
const select = async (conversation: CodingConversationMetadata) => {
if (codingWorkspaceStore.getState().activeProjectId !== projectId) return;
const state = codingConversationStore.getState();
state.primeConversation(createLocalConversationSnapshot(projectId, conversation));
state.markUnread(conversation.id, false);
if (conversation.unread)
void codingWorkspaceStore
.getState()
.patchConversation(conversation.id, { unread: false })
.catch(() => undefined);
navigate('/chat');
await state.selectConversation(conversation.id);
};
return (
<div
role="group"
aria-label="项目会话"
className={compact ? "py-1" : "ml-5 py-1 pl-1"}
data-testid="project-conversations"
>
<div className="classroom-conversation-actions flex items-center gap-1 text-muted-foreground">
<button
type="button"
aria-label="新建对话"
title="新建对话"
disabled={!!busy}
className={`flex flex-1 items-center gap-1.5 rounded py-2 text-xs hover:bg-surface-tertiary ${compact ? "justify-center px-1" : "px-2"}`}
onClick={() =>
void act('new', async () => {
const workspace = codingWorkspaceStore.getState();
const conversation = await workspace.createConversation();
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
setArchived(false);
await select(conversation);
}
})
}
>
<MessageSquarePlus className="h-3.5 w-3.5" />
{!compact && '新对话'}
</button>
<button
aria-label={archived ? '返回最近对话' : '查看归档对话'}
title={archived ? '返回最近对话' : '查看归档对话'}
onClick={() => setArchived(!archived)}
className="p-2"
>
<Archive className="h-3.5 w-3.5" />
</button>
<button aria-label="项目设置" title="项目设置" onClick={() => navigate('/project-config')} className="p-2">
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
{archived && <p className="px-2 py-1 text-xs text-muted-foreground">已归档</p>}
{conversations
.filter((item) => Boolean(item.archivedAt) === archived)
.sort((a, b) =>
(archived ? b.archivedAt! : b.updatedAt).localeCompare(
archived ? a.archivedAt! : a.updatedAt
)
)
.map((item) => {
const summary = summaries[item.id];
const running =
summary &&
['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(summary.runStatus);
return (
<div
key={item.id}
data-selected={selected === item.id}
className={`classroom-conversation-row group/session flex items-center rounded-lg ${selected === item.id ? 'bg-[#e7e9e1]' : 'hover:bg-surface-tertiary'}`}
>
<button
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-2 text-left text-xs"
aria-label={item.title}
title={item.title}
aria-current={selected === item.id ? 'page' : undefined}
onClick={() => void act(item.id, () => select(item))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground/65" /><span className="flex-1 truncate">{item.title}</span>
{running && (
<LoaderCircle className="h-3 w-3 animate-spin" aria-label="对话正在运行" />
)}
{(summary?.unread ?? item.unread) && (
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-label="未读" />
)}
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
aria-label={`对话操作:${item.title}`}
disabled={busy === item.id}
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" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-[100] 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)}
>
<Pencil 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={() =>
void act(item.id, async () => {
await codingWorkspaceStore
.getState()
.patchConversation(item.id, { archived: !item.archivedAt });
if (
!item.archivedAt &&
codingConversationStore.getState().selectedConversationId === item.id
)
codingConversationStore.getState().clearConversationSelection();
})
}
>
{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
? '归档(任务继续运行)'
: '归档'}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
);
})}
{rename && (
<CodingConversationRenameDialog
title={rename.title}
onClose={() => setRename(null)}
onSave={async (title) => {
if (codingWorkspaceStore.getState().activeProjectId !== projectId)
throw new Error('项目已切换');
await codingWorkspaceStore.getState().patchConversation(rename.id, { title });
}}
/>
)}
</div>
);
}