feat(coding): complete PI conversation UI

This commit is contained in:
2026-08-24 08:59:16 +08:00
parent 2613d6530b
commit 863f005203
21 changed files with 2040 additions and 137 deletions

View File

@@ -5,7 +5,6 @@ import {
LoaderCircle,
MessageSquarePlus,
RefreshCw,
Settings2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -16,6 +15,7 @@ import {
uploadCodingAttachment,
type CodingAttachmentRef,
} from '@/lib/coding-attachments';
import { forkCodingConversation } from '@/lib/coding-conversations';
import { cn } from '@/lib/utils';
import {
codingConversationStore,
@@ -28,12 +28,16 @@ import {
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 {
@@ -72,6 +76,16 @@ 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,
@@ -87,6 +101,8 @@ export function CodingChatPanel({
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);
@@ -98,9 +114,14 @@ export function CodingChatPanel({
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[]>
>({});
@@ -132,6 +153,7 @@ export function CodingChatPanel({
? `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(() => (
@@ -172,6 +194,10 @@ export function CodingChatPanel({
targetConversationId,
ACCEPTED_REQUEST_STATUSES,
));
const acceptedPromptRequestCount = useCodingConversationStore((state) => acceptedPromptCount(
state,
targetConversationId,
));
const uncertainRequestCount = useCodingConversationStore((state) => requestCount(
state,
targetConversationId,
@@ -210,6 +236,8 @@ export function CodingChatPanel({
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;
}
@@ -231,6 +259,8 @@ export function CodingChatPanel({
clearConversationSelection,
conversations,
ensureConversation,
markConversationUnread,
patchConversation,
primeConversation,
selectConversation,
selectedAgent,
@@ -277,8 +307,12 @@ export function CodingChatPanel({
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, primeConversation, selectConversation]);
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
@@ -300,6 +334,14 @@ export function CodingChatPanel({
selectedAgent,
]);
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
if (!activeProject || !targetConversationId) return;
const forked = await forkCodingConversation(targetConversationId, sourceEntryId);
upsertConversation(forked);
primeConversation(createLocalConversationSnapshot(activeProject.id, forked));
await selectConversation(forked.id);
}, [activeProject, primeConversation, selectConversation, targetConversationId, upsertConversation]);
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey
|| (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return;
@@ -411,7 +453,7 @@ export function CodingChatPanel({
});
void submitPrompt({
conversationId,
mode: 'prompt',
mode: promptMode,
prepareAttachments: attachments.length > 0
? () => prepareAttachments(attachments)
: undefined,
@@ -437,6 +479,7 @@ export function CodingChatPanel({
draftKey,
localAttachments,
prepareAttachments,
promptMode,
targetConversationId,
submitPrompt,
]);
@@ -451,17 +494,23 @@ export function CodingChatPanel({
|| 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)
&& !running
&& modeMatchesRunState
&& submittingRequestCount === 0
&& !preparationError
&& blockingRequestCount === 0
&& uncertainRequestCount === 0,
&& (running || acceptedPromptRequestCount === 0)
&& blockingRequestCount - acceptedRequestCount === 0
&& uncertainRequestCount === 0
&& pendingInteractionCount === 0,
);
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
@@ -531,21 +580,29 @@ export function CodingChatPanel({
</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]',
targetConversationId === conversation.id
? 'bg-background font-medium shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => handleSelectConversation(conversation)}
>
{conversation.title}
</button>
))}
{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" />
@@ -557,26 +614,34 @@ export function CodingChatPanel({
</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>
<CodingConversationHeader
conversation={selectedConversation}
snapshot={snapshot}
connectionState={connectionState}
onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title });
}}
onArchive={async () => {
if (!targetConversationId) return;
await patchConversation(targetConversationId, { archived: true });
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 || 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">
@@ -601,12 +666,29 @@ export function CodingChatPanel({
<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
conversationId={targetConversationId}
interactions={snapshot?.pendingInteractions ?? []}
onSettled={() => loadConversationSnapshot(targetConversationId, true).then(() => undefined)}
/>
)}
<CodingComposer
value={draft}
editable={editable}
@@ -614,12 +696,17 @@ export function CodingChatPanel({
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) => {
@@ -642,6 +729,18 @@ export function CodingChatPanel({
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
/>
<CodingWorkspaceInspector
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>
);