feat(coding): complete PI conversation UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user