Files
makelore/src/pages/Chat/CodingChatPanel.tsx
inman 04320ddfd4 merge: integrate foreground conversation reconciliation
# Conflicts:
#	tests/e2e/pi-coding-first-chat.spec.ts
2026-09-03 10:47:56 +08:00

767 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
CircleAlert,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
CODING_ATTACHMENT_MAX_BYTES,
CODING_ATTACHMENT_MAX_COUNT,
CODING_ATTACHMENT_MIMES,
CODING_ATTACHMENT_UPLOAD_CONCURRENCY,
uploadCodingAttachment,
type CodingAttachmentRef,
} from '@/lib/coding-attachments';
import {
abortCodingConversation,
forkCodingConversation,
} from '@/lib/coding-conversations';
import { subscribeHostEvent } from '@/lib/host-events';
import {
codingConversationStore,
selectCodingConversationDraft,
selectCodingConversationRequests,
selectCodingConversationSnapshot,
useCodingConversationStore,
type CodingConversationStoreState,
} from '@/stores/coding-conversations';
import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace';
import type {
CodingDraftAttachment,
PromptMode,
} from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
} from '@/types/coding-project';
import { CodingComposer } from './CodingComposer';
import { CodingChangesSummary } from './CodingChangesSummary';
import { CodingConversationSidebar } from './CodingConversationSidebar';
import { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export interface CodingChatPanelProps {
navigationDraft?: string;
onOpenProjectSettings?(): void;
}
interface LocalComposerAttachment {
id: string;
name: string;
file: File;
previewUrl: string;
}
interface LocalSubmissionError {
message: string;
backendCode?: string;
}
function localSubmissionError(error: unknown): LocalSubmissionError {
const message = error instanceof Error ? error.message : String(error);
const details = error && typeof error === 'object'
&& 'details' in error
&& error.details
&& typeof error.details === 'object'
? error.details as { backendCode?: unknown }
: null;
return {
message,
...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}),
};
}
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 SUBMITTING_REQUEST_STATUSES = new Set(['pending']);
const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']);
const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']);
function acceptedPromptCount(
state: CodingConversationStoreState,
conversationId: string | null,
): number {
if (!conversationId) return 0;
return Object.values(selectCodingConversationRequests(conversationId)(state)).filter((request) => (
request.status === 'accepted' && request.mode === 'prompt'
)).length;
}
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 conversationErrorsByProjectId = useCodingWorkspaceStore(
(state) => state.conversationErrorsByProjectId,
);
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 patchConversation = useCodingWorkspaceStore((state) => state.patchConversation);
const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation);
const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId);
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 loadConversationSnapshot = useCodingConversationStore((state) => state.loadSnapshot);
const markConversationUnread = useCodingConversationStore((state) => state.markUnread);
const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId);
const [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record<string, LocalComposerAttachment[]>
>({});
const appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const selectedConversationContextRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
const submissionFlightsRef = useRef(new Set<string>());
const agents = useMemo(() => (
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
), [config]);
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
&& conversation.agentId === selectedAgent?.id
&& !conversation.archivedAt
)) ?? null;
const targetConversationId = selectedConversation?.id ?? null;
const conversationMetadataError = activeProject && targetConversationId
? conversationErrorsByProjectId[activeProject.id]?.[targetConversationId] ?? null
: null;
const provisionalDraftKey = activeProject && selectedAgent
? `new:${activeProject.id}:${selectedAgent.id}`
: null;
const draftKey = targetConversationId ?? provisionalDraftKey;
const abortConversation = useCallback(async (conversationId: string) => {
await abortCodingConversation(conversationId);
await loadConversationSnapshot(conversationId, 'silent');
}, [loadConversationSnapshot]);
const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
const localAttachments = useMemo(() => (
draftKey ? attachmentsByDraftKey[draftKey] ?? [] : []
), [attachmentsByDraftKey, draftKey]);
attachmentsRef.current = attachmentsByDraftKey;
const selectProjectConversation = useCallback((projectId: string, conversationId: string) => {
selectedConversationContextRef.current = `${projectId}:${conversationId}`;
return selectConversation(conversationId);
}, [selectConversation]);
const selectDraft = useCallback((state: CodingConversationStoreState) => (
targetConversationId
? selectCodingConversationDraft(targetConversationId)(state)
: null
), [targetConversationId]);
const selectedDraft = useCodingConversationStore(selectDraft);
const selectSnapshot = useCallback((state: CodingConversationStoreState) => (
targetConversationId
? selectCodingConversationSnapshot(targetConversationId)(state)
: null
), [targetConversationId]);
const snapshot = useCodingConversationStore(selectSnapshot);
const entryLoadState = useCodingConversationStore((state) => (
targetConversationId
? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty'
: 'empty'
));
const entryError = useCodingConversationStore((state) => (
targetConversationId
? state.entriesByConversationId[targetConversationId]?.error ?? null
: null
));
const blockingRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
BLOCKING_REQUEST_STATUSES,
));
const acceptedRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
ACCEPTED_REQUEST_STATUSES,
));
const acceptedPromptRequestCount = useCodingConversationStore((state) => acceptedPromptCount(
state,
targetConversationId,
));
const uncertainRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
UNCERTAIN_REQUEST_STATUSES,
));
const submittingRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
SUBMITTING_REQUEST_STATUSES,
));
useEffect(() => {
void loadWorkspace().catch(() => undefined);
}, [loadWorkspace]);
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
useEffect(() => subscribeHostEvent('lifecycle:sleep', () => {
disconnectEvents();
}), [disconnectEvents]);
useEffect(() => {
if (!targetConversationId) return undefined;
const refreshVisibleConversation = () => {
if (document.visibilityState !== 'visible') return;
void selectConversation(targetConversationId).catch(() => undefined);
};
window.addEventListener('focus', refreshVisibleConversation);
document.addEventListener('visibilitychange', refreshVisibleConversation);
return () => {
window.removeEventListener('focus', refreshVisibleConversation);
document.removeEventListener('visibilitychange', refreshVisibleConversation);
};
}, [selectConversation, targetConversationId]);
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) {
selectedConversationContextRef.current = null;
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) {
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
if (selectedConversationContextRef.current !== selectionContext) {
void selectProjectConversation(activeProject.id, selectedConversation.id)
.catch(() => undefined);
}
return;
}
const next = newestConversation(conversations, selectedAgent.id);
if (next) {
markConversationUnread(next.id, false);
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
void selectProjectConversation(activeProject.id, 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));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === activeProject.id
&& current.selectedAgentId === selectedAgent.id) {
void selectProjectConversation(activeProject.id, conversation.id)
.catch(() => undefined);
}
})
.catch(() => undefined);
}, [
activeProject,
clearConversationSelection,
conversations,
ensureConversation,
markConversationUnread,
patchConversation,
primeConversation,
selectProjectConversation,
selectedAgent,
selectedConversation,
]);
useEffect(() => {
const nextDraft = navigationDraft?.trim();
if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return;
appliedNavigationDraftRef.current = nextDraft;
if (targetConversationId) {
setConversationDraft(targetConversationId, nextDraft);
} else if (provisionalDraftKey) {
setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: nextDraft }));
} else {
return;
}
}, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
useEffect(() => {
if (!targetConversationId) return;
if (provisionalDraft.trim()) {
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId];
if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft);
if (provisionalDraftKey) {
setProvisionalDrafts((drafts) => {
const next = { ...drafts };
delete next[provisionalDraftKey];
return next;
});
}
}
if (provisionalDraftKey) {
setAttachmentsByDraftKey((currentAttachments) => {
const pending = currentAttachments[provisionalDraftKey];
if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments;
const next = { ...currentAttachments, [targetConversationId]: pending };
delete next[provisionalDraftKey];
return next;
});
}
}, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => {
if (!activeProject) return;
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
markConversationUnread(conversation.id, false);
if (conversation.unread) {
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
}
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
const projectId = activeProject.id;
const agentId = selectedAgent.id;
const conversation = await createConversation(agentId).catch(() => null);
if (!conversation) return;
primeConversation(createLocalConversationSnapshot(projectId, conversation));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
}
}, [
activeProject,
createConversation,
creatingAgentIds,
primeConversation,
selectProjectConversation,
selectedAgent,
]);
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
if (!activeProject || !selectedAgent || !targetConversationId) return;
const sourceProjectId = activeProject.id;
const sourceAgentId = selectedAgent.id;
const sourceConversationId = targetConversationId;
const forked = await forkCodingConversation(sourceConversationId, sourceEntryId);
const workspace = codingWorkspaceStore.getState();
if (workspace.activeProjectId !== sourceProjectId) return;
upsertConversation(sourceProjectId, forked);
primeConversation(createLocalConversationSnapshot(sourceProjectId, forked));
const selectedId = codingConversationStore.getState().selectedConversationId;
if (workspace.activeProjectId === sourceProjectId
&& workspace.selectedAgentId === sourceAgentId
&& selectedId === sourceConversationId) {
await selectProjectConversation(sourceProjectId, forked.id);
}
}, [activeProject, primeConversation, selectProjectConversation, selectedAgent, targetConversationId, upsertConversation]);
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey
|| (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return;
const accepted: LocalComposerAttachment[] = [];
const remaining = Math.max(0, CODING_ATTACHMENT_MAX_COUNT - localAttachments.length);
let validationError = files.length > remaining
? `每条消息最多添加 ${CODING_ATTACHMENT_MAX_COUNT} 张图片。`
: null;
for (const file of files.slice(0, remaining)) {
const mime = file.type.trim().toLowerCase();
if (!CODING_ATTACHMENT_MIMES.has(mime)) {
validationError = '仅支持 PNG、JPEG、WebP 或 GIF 图片。';
continue;
}
if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) {
validationError = '图片不能超过 16 MB。';
continue;
}
accepted.push({
id: crypto.randomUUID(),
name: file.name || '图片',
file,
previewUrl: URL.createObjectURL(file),
});
}
setSubmissionErrors((current) => {
const next = { ...current };
if (validationError) next[draftKey] = { message: validationError };
else delete next[draftKey];
return next;
});
if (accepted.length === 0) return;
setAttachmentsByDraftKey((current) => ({
...current,
[draftKey]: [...(current[draftKey] ?? []), ...accepted],
}));
}, [draftKey, localAttachments.length, targetConversationId]);
const handleRemoveAttachment = useCallback((id: string) => {
if (!draftKey
|| (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) 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);
setSubmissionErrors((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
}, [draftKey, targetConversationId]);
const prepareAttachments = useCallback(async (
attachments: LocalComposerAttachment[],
): Promise<CodingDraftAttachment[]> => {
const prepared = new Array<CodingDraftAttachment>(attachments.length);
let nextIndex = 0;
const workers = Array.from({
length: Math.min(CODING_ATTACHMENT_UPLOAD_CONCURRENCY, attachments.length),
}, async () => {
while (nextIndex < attachments.length) {
const index = nextIndex++;
const attachment = attachments[index];
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);
}
}
}
prepared[index] = {
attachmentId: uploaded.attachmentId,
mime: uploaded.mime,
previewUrl: attachment.previewUrl,
};
}
});
const results = await Promise.allSettled(workers);
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
return prepared;
}, []);
const handleSubmit = useCallback(() => {
if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return;
const conversationId = targetConversationId;
const attachments = [...localAttachments];
submissionFlightsRef.current.add(conversationId);
setSubmissionErrors((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
void submitPrompt({
conversationId,
mode: promptMode,
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) => {
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
}).finally(() => {
submissionFlightsRef.current.delete(conversationId);
});
}, [
draftKey,
localAttachments,
prepareAttachments,
promptMode,
targetConversationId,
submitPrompt,
]);
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;
useEffect(() => {
if (!draftKey
|| uncertainRequestCount > 0
|| runtimeError?.code === 'CODING_REQUEST_UNCERTAIN') return;
setSubmissionErrors((current) => {
if (current[draftKey]?.backendCode !== 'CODING_REQUEST_UNCERTAIN') return current;
const next = { ...current };
delete next[draftKey];
return next;
});
}, [draftKey, runtimeError?.code, uncertainRequestCount]);
const preparing = entryLoadState === 'loading'
|| workerStatus === 'starting'
|| workerStatus === 'recovering'
|| runStatus === 'preparing';
const recovering = entryLoadState === 'recovering';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt';
const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => (
interaction.status === 'pending'
)).length ?? 0;
const draft = selectedDraft?.text ?? provisionalDraft;
const editable = Boolean(activeProject && selectedAgent);
const canSend = Boolean(
editable
&& targetConversationId
&& (draft.trim() || localAttachments.length > 0)
&& modeMatchesRunState
&& submittingRequestCount === 0
&& !preparationError
&& (running || acceptedPromptRequestCount === 0)
&& blockingRequestCount - acceptedRequestCount === 0
&& uncertainRequestCount === 0
&& pendingInteractionCount === 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">
Pi
</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">
<CodingConversationSidebar
projectName={activeProject?.name}
agents={agents}
conversations={conversations}
conversationSummaries={conversationSummaries}
selectedAgentId={selectedAgent?.id ?? null}
selectedConversationId={targetConversationId}
creatingAgentIds={creatingAgentIds}
onSelectAgent={(agentId) => {
clearConversationSelection();
selectAgent(agentId);
}}
onSelectConversation={handleSelectConversation}
onCreateConversation={() => void handleCreateConversation()}
onOpenProjectSettings={onOpenProjectSettings}
/>
<div className="flex min-w-0 flex-1 flex-col">
<CodingConversationHeader
key={`header:${targetConversationId ?? 'none'}`}
conversation={selectedConversation}
snapshot={snapshot}
onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title });
}}
onAbort={async () => {
if (targetConversationId) await abortConversation(targetConversationId);
}}
onRecover={async () => {
if (targetConversationId) await recoverConversation(targetConversationId);
}}
/>
{(workspaceError || conversationMetadataError || 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 ?? conversationMetadataError ?? 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>
)}
{targetConversationId
? (
<CodingConversationTimeline
key={targetConversationId}
conversationId={targetConversationId}
assistantName={selectedAgent?.name}
onFork={(sourceEntryId) => {
void handleForkConversation(sourceEntryId).catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
});
}}
/>
)
: (
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
)}
{targetConversationId && (
<CodingInteractionPanel
key={`interaction:${targetConversationId}`}
conversationId={targetConversationId}
interactions={snapshot?.pendingInteractions ?? []}
onSettled={() => loadConversationSnapshot(targetConversationId, 'silent').then(() => undefined)}
/>
)}
{targetConversationId && (
<CodingChangesSummary
key={`changes:${targetConversationId}`}
conversationId={targetConversationId}
snapshot={snapshot}
/>
)}
<CodingComposer
value={draft}
editable={editable}
canSend={canSend}
preparing={preparing || autoCreating}
recovering={recovering}
runStatus={runStatus}
mode={promptMode}
queue={snapshot?.queue ?? { items: [] }}
error={submissionError?.message ?? preparationError}
recoverableError={!submissionError && Boolean(preparationError)}
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '随心输入' : '请选择一个智能体后再发送'}
conversation={selectedConversation}
snapshot={snapshot}
voiceScopeKey={draftKey}
onModeChange={(mode) => {
if (draftKey) setModesByDraftKey((current) => ({ ...current, [draftKey]: mode }));
}}
onChange={(value) => {
if (draftKey) {
setSubmissionErrors((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
}
if (targetConversationId) setConversationDraft(targetConversationId, value);
else if (provisionalDraftKey) {
setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: value }));
}
}}
onSubmit={handleSubmit}
onAbort={() => {
if (!targetConversationId) return;
const conversationId = targetConversationId;
void abortConversation(conversationId)
.catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: localSubmissionError(error),
}));
});
}}
onRecover={() => {
if (targetConversationId) {
void recoverConversation(targetConversationId).catch(() => undefined);
}
}}
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
onRefreshRuntime={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent');
}}
/>
</div>
</section>
);
}