772 lines
33 KiB
TypeScript
772 lines
33 KiB
TypeScript
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>
|
||
);
|
||
}
|