fix(design): separate progress from chat replies
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -47,9 +47,12 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const streams = useMemo(
|
||||
() => Object.entries(assistantStreams).filter(([, text]) => text.trim()),
|
||||
[assistantStreams],
|
||||
const streamingOperationIds = Object.entries(assistantStreams)
|
||||
.filter(([, text]) => text.trim())
|
||||
.map(([operationId]) => operationId);
|
||||
const hasAssistantProgress = streamingOperationIds.length > 0;
|
||||
const hasUnknownAssistantProgress = streamingOperationIds.some(
|
||||
(operationId) => pendingOperations[operationId]?.status === 'unknown',
|
||||
);
|
||||
const submittingChat = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
|
||||
@@ -59,7 +62,7 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
if (typeof scrollAnchor?.scrollIntoView === 'function') {
|
||||
scrollAnchor.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [streams, workspace.turns.length]);
|
||||
}, [hasAssistantProgress, workspace.turns.length]);
|
||||
|
||||
const submit = () => {
|
||||
if (!chatDraft.trim() || submittingChat) return;
|
||||
@@ -84,7 +87,7 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
{workspace.turns.length === 0 && streams.length === 0 && (
|
||||
{workspace.turns.length === 0 && !hasAssistantProgress && (
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
|
||||
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand">
|
||||
<MessageSquareText className="h-5 w-5" />
|
||||
@@ -125,15 +128,28 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streams.map(([operationId, text]) => (
|
||||
{hasAssistantProgress && (
|
||||
<div
|
||||
key={operationId}
|
||||
className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-brand/20 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="design-assistant-progress"
|
||||
className="mr-auto flex max-w-[92%] items-start gap-3 rounded-xl bg-brand/[0.06] px-3.5 py-3 text-muted-foreground shadow-sm"
|
||||
>
|
||||
{text}
|
||||
<span className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle" />
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-background text-brand shadow-sm">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{hasUnknownAssistantProgress ? '正在确认刚才的整理结果' : 'AI 正在整理你的想法'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{hasUnknownAssistantProgress
|
||||
? '结果回来后会显示在对话里,不需要重复发送。'
|
||||
: '这不是新的回复,完成后会显示下一步问题或建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div ref={scrollAnchorRef} />
|
||||
|
||||
@@ -96,7 +96,9 @@ type ImageWorkspaceState = {
|
||||
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
||||
let activeEventSource: EventSource | null = null;
|
||||
let activeEventWorkspaceId: string | null = null;
|
||||
let eventConnectionGeneration = 0;
|
||||
let selectionGeneration = 0;
|
||||
const assistantStreamChunkIndexes = new Map<string, number>();
|
||||
|
||||
function updateBackgroundLease(tasks: DesignGenerationTask[]): void {
|
||||
const active = tasks.some((task) => task.status === 'queued' || task.status === 'running');
|
||||
@@ -112,6 +114,7 @@ function updateBackgroundLease(tasks: DesignGenerationTask[]): void {
|
||||
}
|
||||
|
||||
function closeEventSource(): void {
|
||||
eventConnectionGeneration += 1;
|
||||
if (activeEventSource) {
|
||||
activeEventSource.onopen = null;
|
||||
activeEventSource.onerror = null;
|
||||
@@ -292,6 +295,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
}));
|
||||
try {
|
||||
const result = await submitImageWorkspaceCommand(command);
|
||||
assistantStreamChunkIndexes.delete(operation.id);
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -339,6 +343,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return { pendingOperations, error: null };
|
||||
});
|
||||
} else if (isDefinitiveCommandFailure(error)) {
|
||||
assistantStreamChunkIndexes.delete(operation.id);
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -426,10 +431,12 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const workspace = await createImageWorkspaceProject(title);
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set((state) => ({
|
||||
...withWorkspace(state, workspace),
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
lastEventId: null,
|
||||
}));
|
||||
@@ -467,6 +474,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
if (deletingActive) {
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
}
|
||||
set((state) => ({
|
||||
bootstrap: state.bootstrap ? { ...state.bootstrap, workspaces: remaining } : null,
|
||||
@@ -475,6 +483,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
deletingWorkspaceId: null,
|
||||
fieldDrafts: deletingActive ? {} : state.fieldDrafts,
|
||||
chatDraft: deletingActive ? '' : state.chatDraft,
|
||||
assistantStreams: deletingActive ? {} : state.assistantStreams,
|
||||
error: null,
|
||||
}));
|
||||
if (deletingActive && remaining[0]) {
|
||||
@@ -492,6 +501,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
if (workspaceId === get().activeWorkspaceId && get().workspace) return;
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set({
|
||||
activeWorkspaceId: workspaceId,
|
||||
workspace: null,
|
||||
@@ -499,6 +509,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
lastEventId: null,
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
error: null,
|
||||
});
|
||||
@@ -520,8 +531,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const workspace = get().workspace;
|
||||
if (!workspace) return;
|
||||
const workspaceId = workspace.workspace.workspaceId;
|
||||
if (activeEventSource && activeEventWorkspaceId === workspaceId) return;
|
||||
if (activeEventWorkspaceId === workspaceId) return;
|
||||
closeEventSource();
|
||||
const connectionGeneration = eventConnectionGeneration;
|
||||
activeEventWorkspaceId = workspaceId;
|
||||
set({ eventState: 'connecting' });
|
||||
void openImageWorkspaceEvents(
|
||||
@@ -529,7 +541,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
workspace.workspace.sessionId,
|
||||
get().lastEventId ?? undefined,
|
||||
).then((source) => {
|
||||
if (activeEventWorkspaceId !== workspaceId || get().activeWorkspaceId !== workspaceId) {
|
||||
if (connectionGeneration !== eventConnectionGeneration
|
||||
|| activeEventWorkspaceId !== workspaceId
|
||||
|| get().activeWorkspaceId !== workspaceId) {
|
||||
source.close();
|
||||
return;
|
||||
}
|
||||
@@ -543,6 +557,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const event = parseWorkspaceEvent(raw);
|
||||
if (!event || get().activeWorkspaceId !== workspaceId) return;
|
||||
if (event.type === 'design.assistant.delta') {
|
||||
const previousChunkIndex = assistantStreamChunkIndexes.get(event.clientOperationId);
|
||||
if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) return;
|
||||
assistantStreamChunkIndexes.set(event.clientOperationId, event.chunkIndex);
|
||||
set((state) => ({
|
||||
lastEventId: event.id,
|
||||
assistantStreams: {
|
||||
@@ -592,7 +609,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
'design.workspace.updated',
|
||||
]) source.addEventListener(eventType, receive);
|
||||
}).catch((error) => {
|
||||
if (activeEventWorkspaceId === workspaceId) {
|
||||
if (connectionGeneration === eventConnectionGeneration
|
||||
&& activeEventWorkspaceId === workspaceId) {
|
||||
activeEventWorkspaceId = null;
|
||||
set({ eventState: 'degraded', error: messageForError(error) });
|
||||
}
|
||||
});
|
||||
@@ -708,6 +727,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
reset: () => {
|
||||
selectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
updateBackgroundLease([]);
|
||||
set({
|
||||
status: 'idle',
|
||||
|
||||
Reference in New Issue
Block a user