feat: organize project conversations and add coding teacher side chat
This commit is contained in:
211
src/pages/Chat/CodingProjectConversations.tsx
Normal file
211
src/pages/Chat/CodingProjectConversations.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
LoaderCircle,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Settings2,
|
||||
} 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 }: { projectId: string }) {
|
||||
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="ml-3 border-l border-border/70 py-1 pl-2"
|
||||
data-testid="project-conversations"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建对话"
|
||||
disabled={!!busy}
|
||||
className="flex flex-1 items-center gap-1.5 rounded px-2 py-2 text-xs hover:bg-surface-tertiary"
|
||||
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" />
|
||||
新建会话
|
||||
</button>
|
||||
<button
|
||||
aria-label={archived ? '返回最近对话' : '查看归档对话'}
|
||||
onClick={() => setArchived(!archived)}
|
||||
className="p-2"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button aria-label="项目设置" 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}
|
||||
className={`group/session flex items-center rounded-lg ${selected === item.id ? 'bg-brand-soft' : '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}
|
||||
aria-current={selected === item.id ? 'page' : undefined}
|
||||
onClick={() => void act(item.id, () => select(item))}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user