217 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|