feat: implement PI core chat timeline
This commit is contained in:
574
src/pages/Chat/CodingChatPanel.tsx
Normal file
574
src/pages/Chat/CodingChatPanel.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
MessageSquarePlus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
CODING_ATTACHMENT_MAX_BYTES,
|
||||
CODING_ATTACHMENT_MIMES,
|
||||
uploadCodingAttachment,
|
||||
type CodingAttachmentRef,
|
||||
} from '@/lib/coding-attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
codingConversationStore,
|
||||
selectCodingConversationDraft,
|
||||
selectCodingConversationRequests,
|
||||
selectCodingConversationSnapshot,
|
||||
useCodingConversationStore,
|
||||
type CodingConversationStoreState,
|
||||
} from '@/stores/coding-conversations';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import type {
|
||||
CodingDraftAttachment,
|
||||
} from '@/types/coding-conversation';
|
||||
import type {
|
||||
CodingConversationMetadata,
|
||||
} from '@/types/coding-project';
|
||||
import { CodingComposer } from './CodingComposer';
|
||||
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
||||
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
|
||||
export interface CodingChatPanelProps {
|
||||
navigationDraft?: string;
|
||||
onOpenProjectSettings?(): void;
|
||||
}
|
||||
|
||||
interface LocalComposerAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
function newestConversation(
|
||||
conversations: CodingConversationMetadata[],
|
||||
agentId: string,
|
||||
): CodingConversationMetadata | null {
|
||||
return conversations
|
||||
.filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
function requestCount(
|
||||
state: CodingConversationStoreState,
|
||||
conversationId: string | null,
|
||||
statuses: ReadonlySet<string>,
|
||||
): number {
|
||||
if (!conversationId) return 0;
|
||||
const requests = selectCodingConversationRequests(conversationId)(state);
|
||||
return Object.values(requests).filter((request) => statuses.has(request.status)).length;
|
||||
}
|
||||
|
||||
const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']);
|
||||
const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']);
|
||||
const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']);
|
||||
|
||||
export function CodingChatPanel({
|
||||
navigationDraft,
|
||||
onOpenProjectSettings,
|
||||
}: CodingChatPanelProps) {
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const config = useCodingWorkspaceStore((state) => state.config);
|
||||
const conversations = useCodingWorkspaceStore((state) => state.conversations);
|
||||
const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId);
|
||||
const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState);
|
||||
const workspaceError = useCodingWorkspaceStore((state) => state.error);
|
||||
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
|
||||
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
|
||||
const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent);
|
||||
const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation);
|
||||
const createConversation = useCodingWorkspaceStore((state) => state.createConversation);
|
||||
|
||||
const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId);
|
||||
const connectionState = useCodingConversationStore((state) => state.connectionState);
|
||||
const connectionError = useCodingConversationStore((state) => state.globalError);
|
||||
const primeConversation = useCodingConversationStore((state) => state.primeConversation);
|
||||
const selectConversation = useCodingConversationStore((state) => state.selectConversation);
|
||||
const clearConversationSelection = useCodingConversationStore((state) => state.clearConversationSelection);
|
||||
const disconnectEvents = useCodingConversationStore((state) => state.disconnectEvents);
|
||||
const setConversationDraft = useCodingConversationStore((state) => state.setDraft);
|
||||
const submitPrompt = useCodingConversationStore((state) => state.submitPrompt);
|
||||
const recoverConversation = useCodingConversationStore((state) => state.recoverConversation);
|
||||
|
||||
const [provisionalDraft, setProvisionalDraft] = useState('');
|
||||
const [creatingNewConversation, setCreatingNewConversation] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
||||
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
|
||||
Record<string, LocalComposerAttachment[]>
|
||||
>({});
|
||||
const appliedNavigationDraftRef = useRef<string | null>(null);
|
||||
const automaticCreationKeyRef = useRef<string | null>(null);
|
||||
const attachmentsRef = useRef(attachmentsByDraftKey);
|
||||
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
|
||||
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
|
||||
|
||||
const agents = useMemo(() => (
|
||||
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
|
||||
), [config]);
|
||||
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||
const agentConversations = useMemo(() => (
|
||||
selectedAgent
|
||||
? conversations
|
||||
.filter((conversation) => conversation.agentId === selectedAgent.id && !conversation.archivedAt)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
: []
|
||||
), [conversations, selectedAgent]);
|
||||
const selectedConversation = conversations.find((conversation) => (
|
||||
conversation.id === selectedConversationId
|
||||
)) ?? null;
|
||||
const provisionalDraftKey = activeProject && selectedAgent
|
||||
? `new:${activeProject.id}:${selectedAgent.id}`
|
||||
: null;
|
||||
const draftKey = selectedConversationId ?? provisionalDraftKey;
|
||||
const localAttachments = useMemo(() => (
|
||||
draftKey ? attachmentsByDraftKey[draftKey] ?? [] : []
|
||||
), [attachmentsByDraftKey, draftKey]);
|
||||
|
||||
attachmentsRef.current = attachmentsByDraftKey;
|
||||
|
||||
const selectDraft = useCallback((state: CodingConversationStoreState) => (
|
||||
selectedConversationId
|
||||
? selectCodingConversationDraft(selectedConversationId)(state)
|
||||
: null
|
||||
), [selectedConversationId]);
|
||||
const selectedDraft = useCodingConversationStore(selectDraft);
|
||||
const selectSnapshot = useCallback((state: CodingConversationStoreState) => (
|
||||
selectedConversationId
|
||||
? selectCodingConversationSnapshot(selectedConversationId)(state)
|
||||
: null
|
||||
), [selectedConversationId]);
|
||||
const snapshot = useCodingConversationStore(selectSnapshot);
|
||||
const entryLoadState = useCodingConversationStore((state) => (
|
||||
selectedConversationId
|
||||
? state.entriesByConversationId[selectedConversationId]?.loadState ?? 'empty'
|
||||
: 'empty'
|
||||
));
|
||||
const entryError = useCodingConversationStore((state) => (
|
||||
selectedConversationId
|
||||
? state.entriesByConversationId[selectedConversationId]?.error ?? null
|
||||
: null
|
||||
));
|
||||
const blockingRequestCount = useCodingConversationStore((state) => requestCount(
|
||||
state,
|
||||
selectedConversationId,
|
||||
BLOCKING_REQUEST_STATUSES,
|
||||
));
|
||||
const acceptedRequestCount = useCodingConversationStore((state) => requestCount(
|
||||
state,
|
||||
selectedConversationId,
|
||||
ACCEPTED_REQUEST_STATUSES,
|
||||
));
|
||||
const uncertainRequestCount = useCodingConversationStore((state) => requestCount(
|
||||
state,
|
||||
selectedConversationId,
|
||||
UNCERTAIN_REQUEST_STATUSES,
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspace().catch(() => undefined);
|
||||
}, [loadWorkspace]);
|
||||
|
||||
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const attachments of Object.values(attachmentsRef.current)) {
|
||||
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) return;
|
||||
for (const conversation of conversations) {
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
}
|
||||
}, [activeProject, conversations, primeConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject || !selectedAgent) {
|
||||
clearConversationSelection();
|
||||
return;
|
||||
}
|
||||
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return;
|
||||
const next = newestConversation(conversations, selectedAgent.id);
|
||||
if (next) {
|
||||
void selectConversation(next.id).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
|
||||
if (automaticCreationKeyRef.current === creationKey) return;
|
||||
automaticCreationKeyRef.current = creationKey;
|
||||
void ensureConversation(selectedAgent.id)
|
||||
.then((conversation) => {
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
void selectConversation(conversation.id).catch(() => undefined);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [
|
||||
activeProject,
|
||||
clearConversationSelection,
|
||||
conversations,
|
||||
ensureConversation,
|
||||
primeConversation,
|
||||
selectConversation,
|
||||
selectedAgent,
|
||||
selectedConversation,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextDraft = navigationDraft?.trim();
|
||||
if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return;
|
||||
appliedNavigationDraftRef.current = nextDraft;
|
||||
if (selectedConversationId) {
|
||||
setConversationDraft(selectedConversationId, nextDraft);
|
||||
} else {
|
||||
setProvisionalDraft(nextDraft);
|
||||
}
|
||||
}, [navigationDraft, selectedConversationId, setConversationDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConversationId) return;
|
||||
if (provisionalDraft.trim()) {
|
||||
const current = codingConversationStore.getState().draftsByConversationId[selectedConversationId];
|
||||
if (!current?.text) setConversationDraft(selectedConversationId, provisionalDraft);
|
||||
setProvisionalDraft('');
|
||||
}
|
||||
if (provisionalDraftKey) {
|
||||
setAttachmentsByDraftKey((currentAttachments) => {
|
||||
const pending = currentAttachments[provisionalDraftKey];
|
||||
if (!pending?.length || currentAttachments[selectedConversationId]?.length) return currentAttachments;
|
||||
const next = { ...currentAttachments, [selectedConversationId]: pending };
|
||||
delete next[provisionalDraftKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [provisionalDraft, provisionalDraftKey, selectedConversationId, setConversationDraft]);
|
||||
|
||||
const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => {
|
||||
if (!activeProject) return;
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
void selectConversation(conversation.id).catch(() => undefined);
|
||||
}, [activeProject, primeConversation, selectConversation]);
|
||||
|
||||
const handleCreateConversation = useCallback(async () => {
|
||||
if (!activeProject || !selectedAgent || creatingNewConversation) return;
|
||||
setCreatingNewConversation(true);
|
||||
try {
|
||||
const conversation = await createConversation(selectedAgent.id);
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
await selectConversation(conversation.id).catch(() => undefined);
|
||||
} finally {
|
||||
setCreatingNewConversation(false);
|
||||
}
|
||||
}, [
|
||||
activeProject,
|
||||
createConversation,
|
||||
creatingNewConversation,
|
||||
primeConversation,
|
||||
selectConversation,
|
||||
selectedAgent,
|
||||
]);
|
||||
|
||||
const handleAddFiles = useCallback((files: File[]) => {
|
||||
if (!draftKey) return;
|
||||
const accepted: LocalComposerAttachment[] = [];
|
||||
for (const file of files) {
|
||||
const mime = file.type.trim().toLowerCase();
|
||||
if (!CODING_ATTACHMENT_MIMES.has(mime)) {
|
||||
setSubmissionError('仅支持 PNG、JPEG、WebP 或 GIF 图片。');
|
||||
continue;
|
||||
}
|
||||
if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) {
|
||||
setSubmissionError('图片不能超过 16 MB。');
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name || '图片',
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
});
|
||||
}
|
||||
if (accepted.length === 0) return;
|
||||
setSubmissionError(null);
|
||||
setAttachmentsByDraftKey((current) => ({
|
||||
...current,
|
||||
[draftKey]: [...(current[draftKey] ?? []), ...accepted],
|
||||
}));
|
||||
}, [draftKey]);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
if (!draftKey) return;
|
||||
setAttachmentsByDraftKey((current) => {
|
||||
const attachment = current[draftKey]?.find((item) => item.id === id);
|
||||
if (attachment) URL.revokeObjectURL(attachment.previewUrl);
|
||||
const nextItems = (current[draftKey] ?? []).filter((item) => item.id !== id);
|
||||
const next = { ...current, [draftKey]: nextItems };
|
||||
if (nextItems.length === 0) delete next[draftKey];
|
||||
return next;
|
||||
});
|
||||
uploadedAttachmentsRef.current.delete(id);
|
||||
uploadFlightsRef.current.delete(id);
|
||||
}, [draftKey]);
|
||||
|
||||
const prepareAttachments = useCallback(async (
|
||||
attachments: LocalComposerAttachment[],
|
||||
): Promise<CodingDraftAttachment[]> => await Promise.all(attachments.map(async (attachment) => {
|
||||
let uploaded = uploadedAttachmentsRef.current.get(attachment.id);
|
||||
if (!uploaded) {
|
||||
let flight = uploadFlightsRef.current.get(attachment.id);
|
||||
if (!flight) {
|
||||
flight = uploadCodingAttachment(attachment.file);
|
||||
uploadFlightsRef.current.set(attachment.id, flight);
|
||||
}
|
||||
try {
|
||||
uploaded = await flight;
|
||||
uploadedAttachmentsRef.current.set(attachment.id, uploaded);
|
||||
} finally {
|
||||
if (uploadFlightsRef.current.get(attachment.id) === flight) {
|
||||
uploadFlightsRef.current.delete(attachment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
attachmentId: uploaded.attachmentId,
|
||||
mime: uploaded.mime,
|
||||
previewUrl: attachment.previewUrl,
|
||||
};
|
||||
})), []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!selectedConversationId || !draftKey || submitting) return;
|
||||
const attachments = [...localAttachments];
|
||||
setSubmitting(true);
|
||||
setSubmissionError(null);
|
||||
void submitPrompt({
|
||||
conversationId: selectedConversationId,
|
||||
mode: 'prompt',
|
||||
prepareAttachments: attachments.length > 0
|
||||
? () => prepareAttachments(attachments)
|
||||
: undefined,
|
||||
}).then(() => {
|
||||
setAttachmentsByDraftKey((current) => {
|
||||
const next = { ...current };
|
||||
delete next[draftKey];
|
||||
return next;
|
||||
});
|
||||
for (const attachment of attachments) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
uploadedAttachmentsRef.current.delete(attachment.id);
|
||||
}
|
||||
}).catch((error) => {
|
||||
setSubmissionError(error instanceof Error ? error.message : String(error));
|
||||
}).finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
}, [
|
||||
draftKey,
|
||||
localAttachments,
|
||||
prepareAttachments,
|
||||
selectedConversationId,
|
||||
submitPrompt,
|
||||
submitting,
|
||||
]);
|
||||
|
||||
const runStatus = snapshot?.run.status ?? 'idle';
|
||||
const workerStatus = snapshot?.worker.status ?? 'stopped';
|
||||
const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null;
|
||||
const preparationError = entryError ?? runtimeError?.message ?? null;
|
||||
const preparing = entryLoadState === 'loading'
|
||||
|| workerStatus === 'starting'
|
||||
|| workerStatus === 'recovering'
|
||||
|| runStatus === 'preparing';
|
||||
const recovering = entryLoadState === 'recovering';
|
||||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
||||
const draft = selectedDraft?.text ?? provisionalDraft;
|
||||
const editable = Boolean(activeProject && selectedAgent);
|
||||
const canSend = Boolean(
|
||||
editable
|
||||
&& selectedConversationId
|
||||
&& (draft.trim() || localAttachments.length > 0)
|
||||
&& !running
|
||||
&& !submitting
|
||||
&& !preparationError
|
||||
&& blockingRequestCount === 0
|
||||
&& uncertainRequestCount === 0,
|
||||
);
|
||||
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
|
||||
|
||||
if (!activeProject && workspaceLoadState !== 'loading') {
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 items-center justify-center bg-background p-6" data-testid="coding-chat-empty-project">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-lg font-semibold">先选择一个编程项目</h1>
|
||||
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
项目和伙伴信息只在本地读取,不会为了打开输入框启动 Agent。
|
||||
</p>
|
||||
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
|
||||
打开项目设置
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-foreground/10 bg-surface-subtle/45 md:flex">
|
||||
<div className="border-b border-foreground/10 px-4 py-4">
|
||||
<p className="truncate text-sm font-semibold">{activeProject?.name ?? 'Makelore Code'}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">本地 Conversation</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">伙伴</p>
|
||||
<div className="space-y-1">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
selectedAgent?.id === agent.id ? 'bg-background font-medium shadow-soft' : 'hover:bg-background/70',
|
||||
)}
|
||||
onClick={() => selectAgent(agent.id)}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
|
||||
{agent.name.trim().slice(0, 1) || 'A'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex min-h-10 items-center justify-between px-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">对话</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
aria-label="新建对话"
|
||||
disabled={!selectedAgent || creatingNewConversation}
|
||||
onClick={() => void handleCreateConversation()}
|
||||
>
|
||||
{creatingNewConversation
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{agentConversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-h-10 w-full truncate rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
selectedConversationId === conversation.id
|
||||
? 'bg-background font-medium shadow-soft'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
||||
)}
|
||||
onClick={() => handleSelectConversation(conversation)}
|
||||
>
|
||||
{conversation.title}
|
||||
</button>
|
||||
))}
|
||||
{autoCreating && (
|
||||
<div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
正在创建首个对话…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b border-foreground/10 px-4 sm:px-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{selectedConversation?.title ?? '新对话'}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{selectedAgent?.name ?? '尚未选择伙伴'}
|
||||
{connectionState === 'reconnecting' ? ' · 正在重连' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{workspaceLoadState === 'loading' && <LoaderCircle className="h-4 w-4 animate-spin text-muted-foreground" aria-label="正在刷新项目信息" />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
aria-label="伙伴设置"
|
||||
onClick={onOpenProjectSettings}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{(workspaceError || connectionError) && (
|
||||
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
|
||||
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-pretty">{workspaceError ?? connectionError}</p>
|
||||
{workspaceError && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-10 rounded-xl px-3 text-destructive transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
onClick={() => void loadWorkspace().catch(() => undefined)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversationId
|
||||
? (
|
||||
<CodingConversationTimeline
|
||||
key={selectedConversationId}
|
||||
conversationId={selectedConversationId}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
|
||||
)}
|
||||
|
||||
<CodingComposer
|
||||
value={draft}
|
||||
editable={editable}
|
||||
canSend={canSend}
|
||||
preparing={preparing || autoCreating}
|
||||
recovering={recovering}
|
||||
runStatus={runStatus}
|
||||
error={submissionError ?? preparationError}
|
||||
recoverableError={!submissionError && Boolean(preparationError)}
|
||||
acceptedCount={acceptedRequestCount}
|
||||
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
|
||||
submitting={submitting}
|
||||
placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'}
|
||||
onChange={(value) => {
|
||||
setSubmissionError(null);
|
||||
if (selectedConversationId) setConversationDraft(selectedConversationId, value);
|
||||
else setProvisionalDraft(value);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onRecover={() => {
|
||||
if (selectedConversationId) {
|
||||
void recoverConversation(selectedConversationId).catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
onAddFiles={handleAddFiles}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
173
src/pages/Chat/CodingComposer.tsx
Normal file
173
src/pages/Chat/CodingComposer.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { LoaderCircle, Plus, RotateCcw, Send, X } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConversationRunStatus } from '@/types/coding-conversation';
|
||||
|
||||
export interface CodingComposerProps {
|
||||
value: string;
|
||||
editable: boolean;
|
||||
canSend: boolean;
|
||||
preparing: boolean;
|
||||
recovering: boolean;
|
||||
runStatus: ConversationRunStatus;
|
||||
error: string | null;
|
||||
recoverableError: boolean;
|
||||
acceptedCount: number;
|
||||
attachments: Array<{ id: string; name: string; previewUrl: string }>;
|
||||
submitting: boolean;
|
||||
placeholder: string;
|
||||
onChange(value: string): void;
|
||||
onSubmit(): void;
|
||||
onRecover(): void;
|
||||
onAddFiles(files: File[]): void;
|
||||
onRemoveAttachment(id: string): void;
|
||||
}
|
||||
|
||||
export function CodingComposer({
|
||||
value,
|
||||
editable,
|
||||
canSend,
|
||||
preparing,
|
||||
recovering,
|
||||
runStatus,
|
||||
error,
|
||||
recoverableError,
|
||||
acceptedCount,
|
||||
attachments,
|
||||
submitting,
|
||||
placeholder,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onRecover,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
}: CodingComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
||||
return (
|
||||
<form
|
||||
data-testid="coding-message-composer"
|
||||
className="relative z-20 mx-auto w-[calc(100%_-_2rem)] max-w-[46rem] shrink-0 pb-4 pt-2 sm:pb-5 sm:pt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSend) onSubmit();
|
||||
}}
|
||||
>
|
||||
{(preparing || recovering || submitting || error || acceptedCount > 0 || running) && (
|
||||
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-xs text-muted-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.05)]">
|
||||
{(preparing || recovering || submitting) && <LoaderCircle className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />}
|
||||
<span className="min-w-0 flex-1 text-pretty" role={error ? 'alert' : 'status'}>
|
||||
{error
|
||||
?? (recovering
|
||||
? '正在重新连接本地 Agent…'
|
||||
: submitting
|
||||
? '正在准备图片并提交…'
|
||||
: acceptedCount > 0
|
||||
? `${acceptedCount} 条消息已被本地 Agent 接收。`
|
||||
: preparing
|
||||
? '正在准备本地 Agent,输入框仍可编辑。'
|
||||
: running
|
||||
? '当前对话正在处理。'
|
||||
: '')}
|
||||
</span>
|
||||
{error && recoverableError && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-10 shrink-0 rounded-xl px-3 transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
disabled={recovering}
|
||||
onClick={onRecover}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
multiple
|
||||
className="sr-only"
|
||||
data-testid="coding-file-attachment-input"
|
||||
onChange={(event) => {
|
||||
onAddFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="chat-composer-surface rounded-2xl bg-background p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.10),0_2px_8px_rgba(0,0,0,0.06)] focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.16),0_4px_14px_rgba(0,0,0,0.07)]">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-1 pb-1" aria-label="已添加到本次消息的图片">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="relative h-16 w-16 overflow-hidden rounded-xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt={attachment.name}
|
||||
className="h-full w-full object-cover outline outline-1 -outline-offset-1 outline-black/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 flex h-10 w-10 translate-x-2 -translate-y-2 items-end justify-start rounded-full bg-foreground/85 p-1.5 text-background transition-[background-color,scale] duration-150 ease-out hover:bg-foreground active:scale-[0.96]"
|
||||
aria-label={`移除图片 ${attachment.name}`}
|
||||
onClick={() => onRemoveAttachment(attachment.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={value}
|
||||
rows={1}
|
||||
disabled={!editable}
|
||||
placeholder={placeholder}
|
||||
className="max-h-40 !min-h-[48px] resize-none border-0 bg-transparent px-2 py-2.5 text-sm leading-6 shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return;
|
||||
event.preventDefault();
|
||||
if (canSend) onSubmit();
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/'));
|
||||
if (files.length > 0) onAddFiles(files);
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-10 items-center justify-between gap-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 shrink-0 rounded-xl p-0 transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
disabled={!editable || submitting}
|
||||
aria-label="添加图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<p className="min-w-0 truncate px-1 text-xs text-muted-foreground">
|
||||
{running ? '排队与引导发送将在下一阶段接入' : 'Enter 发送 · Shift + Enter 换行'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'h-10 w-10 shrink-0 rounded-xl bg-foreground p-0 text-background shadow-soft hover:bg-foreground/90',
|
||||
'transition-transform duration-150 ease-out active:scale-[0.96]',
|
||||
)}
|
||||
disabled={!canSend}
|
||||
aria-label="发送"
|
||||
>
|
||||
{submitting
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: <Send className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
370
src/pages/Chat/CodingConversationTimeline.tsx
Normal file
370
src/pages/Chat/CodingConversationTimeline.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
FileImage,
|
||||
Hammer,
|
||||
Layers3,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { loadCodingAttachmentPreview } from '@/lib/coding-attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
selectCodingConversationSnapshot,
|
||||
useCodingConversationStore,
|
||||
type CodingConversationStoreState,
|
||||
} from '@/stores/coding-conversations';
|
||||
import type {
|
||||
ConversationBoundaryNode,
|
||||
ConversationCompactionNode,
|
||||
ConversationContentBlock,
|
||||
ConversationMessageNode,
|
||||
ConversationNode,
|
||||
ConversationNoticeNode,
|
||||
ConversationToolNode,
|
||||
} from '../../../shared/coding-conversation-contracts';
|
||||
|
||||
const EMPTY_NODES: ConversationNode[] = [];
|
||||
const INITIAL_WINDOW = 120;
|
||||
const WINDOW_STEP = 100;
|
||||
const LONG_OUTPUT_CHARS = 4_000;
|
||||
|
||||
const MarkdownText = memo(function MarkdownText({ text }: { text: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} className="font-medium text-brand underline underline-offset-2">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className, ...props }) => (
|
||||
<code
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded bg-foreground/[0.06] px-1 py-0.5 font-mono text-[0.92em]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-3 max-w-full overflow-x-auto rounded-xl bg-foreground/[0.04] p-3 font-mono text-xs leading-5">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
p: ({ children }) => <p className="text-pretty leading-7">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
const AttachmentImage = memo(function AttachmentImage({
|
||||
attachmentId,
|
||||
mime,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
mime: string;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
void loadCodingAttachmentPreview(attachmentId)
|
||||
.then((preview) => {
|
||||
if (!active) return;
|
||||
const bytes = Uint8Array.from(preview.bytes);
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mime }));
|
||||
setPreviewUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachmentId]);
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="max-w-sm overflow-hidden rounded-2xl bg-foreground/[0.04] shadow-[0_0_0_1px_rgba(0,0,0,0.06)]"
|
||||
data-attachment-id={attachmentId}
|
||||
>
|
||||
{previewUrl
|
||||
? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="对话图片附件"
|
||||
className="max-h-80 w-full object-contain outline outline-1 -outline-offset-1 outline-black/10"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex min-h-28 items-center justify-center gap-2 px-4 text-sm text-muted-foreground">
|
||||
<FileImage className="h-5 w-5" aria-hidden="true" />
|
||||
{failed ? '图片预览不可用' : '正在加载图片…'}
|
||||
</div>
|
||||
)}
|
||||
<figcaption className="truncate px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{mime} · {attachmentId}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
});
|
||||
|
||||
const ContentBlock = memo(function ContentBlock({
|
||||
block,
|
||||
markdown,
|
||||
}: {
|
||||
block: ConversationContentBlock;
|
||||
markdown: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(
|
||||
block.kind === 'thinking' && block.status === 'streaming',
|
||||
);
|
||||
if (block.kind === 'image') {
|
||||
return <AttachmentImage attachmentId={block.attachmentId} mime={block.mime} />;
|
||||
}
|
||||
if (block.kind === 'thinking') {
|
||||
return (
|
||||
<details
|
||||
className="group rounded-xl bg-foreground/[0.035] px-3 py-2.5"
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Brain className="h-4 w-4" aria-hidden="true" />
|
||||
思考过程
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<p className="whitespace-pre-wrap break-words pb-1 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
{block.text}
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
if (!markdown) {
|
||||
return <p className="whitespace-pre-wrap break-words text-pretty leading-7">{block.text}</p>;
|
||||
}
|
||||
return <MarkdownText text={block.text} />;
|
||||
});
|
||||
|
||||
const MessageNode = memo(function MessageNode({ node }: { node: ConversationMessageNode }) {
|
||||
const user = node.role === 'user';
|
||||
return (
|
||||
<article
|
||||
data-node-id={node.id}
|
||||
data-node-kind="message"
|
||||
className={cn('flex gap-3', user && 'justify-end')}
|
||||
>
|
||||
{!user && (
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-foreground text-background shadow-soft">
|
||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-[min(46rem,88%)] space-y-2.5 text-sm text-foreground',
|
||||
user
|
||||
? 'rounded-2xl rounded-br-md bg-foreground px-4 py-2.5 text-background shadow-soft'
|
||||
: 'flex-1 py-1.5',
|
||||
node.status === 'error' && 'shadow-[0_0_0_1px_hsl(var(--destructive)/0.25)]',
|
||||
)}
|
||||
>
|
||||
{node.blocks.map((block) => (
|
||||
<ContentBlock key={block.id} block={block} markdown={!user} />
|
||||
))}
|
||||
{node.status === 'optimistic' && (
|
||||
<p className={cn('text-xs', user ? 'text-background/65' : 'text-muted-foreground')}>
|
||||
正在提交…
|
||||
</p>
|
||||
)}
|
||||
{node.status === 'error' && (
|
||||
<p className={cn('text-xs font-medium', user ? 'text-background/80' : 'text-destructive')}>
|
||||
本次提交未被接受,内容已保留在输入框。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-subtle text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }) {
|
||||
const outputChars = node.output.reduce((total, block) => (
|
||||
total + (block.kind === 'image' ? 0 : block.text.length)
|
||||
), 0);
|
||||
const open = node.status === 'running' || node.status === 'waiting';
|
||||
const [expanded, setExpanded] = useState(open);
|
||||
return (
|
||||
<details
|
||||
data-node-id={node.id}
|
||||
data-node-kind="tool"
|
||||
className="group ml-12 rounded-2xl bg-card p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_2px_5px_rgba(0,0,0,0.04)]"
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 rounded-xl px-2 text-sm font-medium hover:bg-surface-subtle">
|
||||
<Hammer className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">{node.title || node.toolName}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{node.status}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="space-y-3 px-2 pb-2 pt-1">
|
||||
{node.inputText && (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-foreground/[0.035] p-3 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{node.inputText}
|
||||
</pre>
|
||||
)}
|
||||
{node.output.map((block) => (
|
||||
<ContentBlock key={block.id} block={block} markdown={false} />
|
||||
))}
|
||||
{outputChars > LONG_OUTPUT_CHARS && (
|
||||
<p className="text-xs text-muted-foreground">长输出已收纳在工具卡片中。</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
|
||||
const CompactionNode = memo(function CompactionNodeView({ node }: { node: ConversationCompactionNode }) {
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="compaction"
|
||||
className="ml-12 flex items-start gap-3 rounded-2xl bg-brand-soft/70 px-4 py-3 text-sm shadow-[0_0_0_1px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<Layers3 className="mt-0.5 h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">上下文整理 · {node.status}</p>
|
||||
{node.summary && <p className="mt-1 text-pretty text-muted-foreground">{node.summary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BoundaryNode = memo(function BoundaryNodeView({ node }: { node: ConversationBoundaryNode }) {
|
||||
const label = node.boundary === 'turn-start'
|
||||
? '开始新一轮'
|
||||
: node.boundary === 'turn-end'
|
||||
? '本轮完成'
|
||||
: `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}`;
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="boundary"
|
||||
className="ml-12 flex items-center gap-3 py-1 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<span className="h-px flex-1 bg-foreground/10" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
<span className="h-px flex-1 bg-foreground/10" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const NoticeNode = memo(function NoticeNodeView({ node }: { node: ConversationNoticeNode }) {
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="notice"
|
||||
className="ml-12 flex items-start gap-2 rounded-xl bg-surface-subtle px-3 py-2.5 text-sm"
|
||||
>
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-pretty">{node.message}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TimelineNode = memo(function TimelineNode({ node }: { node: ConversationNode }) {
|
||||
if (node.kind === 'message') return <MessageNode node={node} />;
|
||||
if (node.kind === 'tool') return <ToolNode node={node} />;
|
||||
if (node.kind === 'compaction') return <CompactionNode node={node} />;
|
||||
if (node.kind === 'boundary') return <BoundaryNode node={node} />;
|
||||
if (node.kind === 'notice') return <NoticeNode node={node} />;
|
||||
return (
|
||||
<div data-node-id={node.id} data-node-kind={node.kind} className="ml-12 rounded-xl bg-surface-subtle px-3 py-2 text-sm text-muted-foreground">
|
||||
子任务执行状态将在后续功能面板中展开。
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const CodingConversationTimeline = memo(function CodingConversationTimeline({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) {
|
||||
const selectNodes = useCallback((state: CodingConversationStoreState) => (
|
||||
selectCodingConversationSnapshot(conversationId)(state)?.nodes ?? EMPTY_NODES
|
||||
), [conversationId]);
|
||||
const nodes = useCodingConversationStore(selectNodes);
|
||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
|
||||
const windowStart = Math.max(0, nodes.length - visibleLimit);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const element = scrollRef.current;
|
||||
if (element) element.scrollTop = element.scrollHeight;
|
||||
}, [nodes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
data-testid="coding-conversation-timeline"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
||||
{windowStart > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="mx-auto min-h-10 rounded-xl px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)}
|
||||
>
|
||||
加载更早内容
|
||||
</Button>
|
||||
)}
|
||||
{nodes.slice(windowStart).map((node) => <TimelineNode key={node.id} node={node} />)}
|
||||
{nodes.length === 0 && (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-balance text-base font-semibold">从一个清晰的问题开始</h2>
|
||||
<p className="mt-1.5 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
输入框会先于本地 Agent 就绪,你可以立即整理需求。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
28
src/pages/Chat/coding-chat-snapshot.ts
Normal file
28
src/pages/Chat/coding-chat-snapshot.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||
import type { CodingConversationMetadata } from '@/types/coding-project';
|
||||
|
||||
export function createLocalConversationSnapshot(
|
||||
projectId: string,
|
||||
conversation: CodingConversationMetadata,
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
projectId,
|
||||
agentId: conversation.agentId,
|
||||
title: conversation.title,
|
||||
model: {
|
||||
model: conversation.model,
|
||||
modelResolution: conversation.modelResolution,
|
||||
},
|
||||
},
|
||||
nodes: [],
|
||||
run: { status: 'idle' },
|
||||
queue: { items: [] },
|
||||
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'stopped', generation: 0 },
|
||||
cursor: { workerGeneration: 0, seq: 0 },
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpencodeChatPanel } from './OpencodeChatPanel';
|
||||
import { CodingChatPanel } from './CodingChatPanel';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function getNavigationDraft(state: unknown): string | undefined {
|
||||
@@ -22,8 +22,7 @@ export function Chat() {
|
||||
|
||||
return (
|
||||
<div data-testid="chat-operation-page" className="flex h-[calc(100%_+_3rem)] min-h-0 -m-6 overflow-hidden bg-background">
|
||||
<OpencodeChatPanel
|
||||
variant="main"
|
||||
<CodingChatPanel
|
||||
navigationDraft={navigationDraft}
|
||||
onOpenProjectSettings={() => navigate('/project-config')}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user