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

772 lines
33 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,
LoaderCircle,
MessageSquarePlus,
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 { forkCodingConversation } from '@/lib/coding-conversations';
import { cn } from '@/lib/utils';
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 { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWorkspaceInspector } from './CodingWorkspaceInspector';
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 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 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 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, string>>({});
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
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 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 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
&& 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 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 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(() => () => {
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) {
markConversationUnread(next.id, false);
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
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));
const current = codingWorkspaceStore.getState();
if (current.activeProjectId === activeProject.id
&& current.selectedAgentId === selectedAgent.id) {
void selectConversation(conversation.id).catch(() => undefined);
}
})
.catch(() => undefined);
}, [
activeProject,
clearConversationSelection,
conversations,
ensureConversation,
markConversationUnread,
patchConversation,
primeConversation,
selectConversation,
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 selectConversation(conversation.id).catch(() => undefined);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]);
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 selectConversation(conversation.id).catch(() => undefined);
}
}, [
activeProject,
createConversation,
creatingAgentIds,
primeConversation,
selectConversation,
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 selectConversation(forked.id);
}
}, [activeProject, primeConversation, selectConversation, 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] = 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]: error instanceof Error ? error.message : String(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;
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">
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={() => {
clearConversationSelection();
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 || Boolean(selectedAgent && creatingAgentIds[selectedAgent.id])}
onClick={() => void handleCreateConversation()}
>
{selectedAgent && creatingAgentIds[selectedAgent.id]
? <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) => {
const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread;
const summary = conversationSummaries[conversation.id];
return (
<button
key={conversation.id}
type="button"
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
targetConversationId === conversation.id
? 'bg-background font-medium shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => handleSelectConversation(conversation)}
>
<span className="min-w-0 flex-1 truncate">{conversation.title}</span>
{summary && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(summary.runStatus) && (
<LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-label="对话正在运行" />
)}
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-brand" aria-label="未读" />}
</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">
<CodingConversationHeader
key={`header:${targetConversationId ?? 'none'}`}
conversation={selectedConversation}
snapshot={snapshot}
connectionState={connectionState}
onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title });
}}
onArchive={async () => {
if (!targetConversationId) return;
const sourceConversationId = targetConversationId;
await patchConversation(sourceConversationId, { archived: true });
if (codingConversationStore.getState().selectedConversationId === sourceConversationId) {
clearConversationSelection();
}
}}
onToggleUnread={async () => {
if (!targetConversationId || !selectedConversation) return;
const unread = !selectedConversation.unread;
await patchConversation(targetConversationId, { unread });
markConversationUnread(targetConversationId, unread);
}}
onFork={() => handleForkConversation()}
onRefresh={async () => {
if (targetConversationId) await loadConversationSnapshot(targetConversationId, true);
}}
onRecover={async () => {
if (targetConversationId) await recoverConversation(targetConversationId);
}}
onOpenInspector={() => setInspectorOpen(true)}
onOpenSettings={onOpenProjectSettings}
/>
{(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}
onFork={(sourceEntryId) => {
void handleForkConversation(sourceEntryId).catch((error) => {
if (!draftKey) return;
setSubmissionErrors((current) => ({
...current,
[draftKey]: error instanceof Error ? error.message : String(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, true).then(() => undefined)}
/>
)}
<CodingComposer
value={draft}
editable={editable}
canSend={canSend}
preparing={preparing || autoCreating}
recovering={recovering}
runStatus={runStatus}
mode={promptMode}
queue={snapshot?.queue ?? { items: [] }}
error={submissionError ?? preparationError}
recoverableError={!submissionError && Boolean(preparationError)}
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submittingRequestCount > 0}
placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'}
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}
onRecover={() => {
if (targetConversationId) {
void recoverConversation(targetConversationId).catch(() => undefined);
}
}}
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
/>
<CodingWorkspaceInspector
key={`inspector:${targetConversationId ?? 'none'}`}
open={inspectorOpen}
onOpenChange={setInspectorOpen}
conversationId={targetConversationId}
agentId={selectedAgent?.id ?? null}
snapshot={snapshot}
onUseCommand={(command) => {
if (!targetConversationId) return;
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]?.text ?? '';
setConversationDraft(targetConversationId, current ? `${current}\n${command}` : command);
}}
/>
</div>
</section>
);
}