Compare commits

...
2 Commits
Author SHA1 Message Date
brother7 403236115b fix(design): show submitted messages immediately
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-04 08:38:13 +08:00
brother7 c6b0490d7f fix(design): separate progress from chat replies 2026-09-04 08:22:32 +08:00
5 changed files with 496 additions and 23 deletions

No files matched your search

@@ -0,0 +1,154 @@
# Task: Separate AI Design summary from chat messages
## Identity
- Task ID: 20260904-design-summary-separation-9c4e7a21
- Mode: Feature
- Branch: codex/20260904-design-summary-separation-9c4e7a21-design-summary-separation
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260904-design-summary-separation-9c4e7a21
- Base commit: 945b40de11efde02fadb7ec6721de8c0b7a3aa85
- Owner: codex-root
- Status: Ready for Integration
## Scope
- Separate in-progress AI Design reasoning from committed conversation messages in
the ImageCanvas Renderer. Raw assistant stream text must not appear as a finished
chat bubble; the UI should show an explicit, non-content progress state until the
canonical turn arrives.
- Close the supported duplicate-stream path caused by overlapping event-source opens
and repeated/out-of-order `chunkIndex` delivery.
- Project a newly submitted chat command into the conversation immediately, before the
server returns the canonical turn. Keep the provisional message visually honest,
replace it with the canonical turn without duplication, and restore the draft after
a definitive failure.
- Add focused Renderer and Store regression tests. Do not change Works Square server,
Electron Main DTO/API, Current Specification authority, Quote/generation, Plugin
navigation, packaging, publication, or the three foreign root task records.
## Intent And Constraints
- The screenshot proves that transient stream content is visually indistinguishable
from an assistant reply and that chunks are duplicated. A progress draft is not a
semantic turn and must not be presented as one.
- Keep the final persisted assistant question visible in the conversation after the
command settles. Do not parse model prose to guess where a summary ends or a
question begins.
- Preserve stable operation identity and transport-unknown state. A temporarily
uncertain accepted command may retain its internal stream state, but unfinished
prose remains hidden behind an honest status label.
- Preserve ADR-007: Current Specification remains the semantic authority and the
right-side “AI 听懂的想法” remains its secondary projection.
- Keep Chinese-only copy, reduced-motion behavior, at least 40 px interactive targets,
and the current responsive single-mount behavior.
- Do not create a second chat state machine: the existing pending operation is the
provisional message identity, while only the returned Workspace turn is canonical.
## Project Context Loaded
- Concurrent Task Gate: passed in an isolated managed worktree because root `main`
remains owned by integration task `20260904-integrate-guided-design-6a3f9c82` and
contains three explicitly preserved foreign untracked task records.
- Planning Gate: passed after reading the active task, startup memory set, integrated
state, ADR-007, system overview, business rules, success criteria, evidence index,
and relevant peer scopes.
- Relevant peers: the old Enter-only E2E task owns only one historical test path; the
undefined August assessment task has no implementation scope. Neither semantically
conflicts with this isolated current-frontier ImageCanvas change. Plugin-navigation
tasks are in separate worktrees and outside scope.
- `project-positioning.md` and top-level project success remain placeholders; concrete
authority comes from ADR-007 and populated current-state, architecture, domain, and
prior Design task records.
- Likely files: `src/pages/ImageCanvas/DesignConversationPane.tsx`,
`src/stores/image-workspace.ts`, `tests/unit/image-canvas-page.test.tsx`, and
`tests/unit/image-workspace-store.test.ts`.
- Gate result: Passed.
## Outcome
- A chat command now appears in the conversation immediately from its existing
`PendingDesignOperation`, before the server returns the canonical Workspace turn.
The provisional user bubble carries a small `发送中` state, changes to `正在确认`
for an unknown accepted outcome, and is filtered to the active Workspace.
- While that exact draft is projected as a provisional bubble, the composer looks
cleared without deleting the stored draft. Canonical success atomically replaces
the provisional bubble with the persisted turn; definitive failure removes the
pending projection and naturally restores the original draft for correction/retry.
- First-use guidance is hidden as soon as the first provisional user message exists.
Other in-flight Design input commands retain the existing composer lock, so the UX
change does not introduce concurrent reducer writes.
- `DesignConversationPane` no longer renders raw `assistantStreams` prose as an
assistant chat bubble. It renders one compact `role=status` surface that explicitly
says the AI is organizing the idea and that the content is not a new reply. A
transport-unknown operation instead says the existing result is being confirmed and
must not be resent.
- Final canonical `turn.assistantMessage` content remains in the ordinary conversation;
the implementation does not parse or classify model prose.
- Event connection startup now has a generation fence. An open that resolves after a
disconnect/replacement closes itself, so React remounts cannot leave two live sources
consuming the same stream. A failed open releases the pending connection identity so
a later retry remains possible.
- Assistant `chunkIndex` is now consumed as the ordering/deduplication contract;
replayed or older chunks do not append duplicate text. Chunk progress is cleared with
successful/definitive settlement and Workspace reset/switch/delete.
- The root `main` worktree and its three foreign untracked task records were not
modified.
## Verification
- New optimistic-message red baseline: Store publication passed, while the Renderer
regression failed because no `发送中` message existed, the first-use prompt remained,
and the composer still showed the submitted draft. After the fix, the two focused
files pass `27/27`, including submitting → unknown → canonical replacement.
- Adjacent ImageCanvas, Store, Fine Tune, youth summary/projection/copy tests now pass
`6 files / 51 tests`.
- Follow-up TypeScript `tsc --noEmit`, changed-file ESLint, and production
Renderer/Main/Preload/utility Vite build pass. Build output contains only the
existing browserslist, dynamic-import, and large-chunk warnings.
- Electron/Playwright AI Design checks both pass individually: Prompt Museum media
isolation passed in the two-test run; the conversation/adjustment/Quote/Task flow
initially timed out at the pre-Design auth-fixture readiness gate, then passed `1/1`
in isolation in 1.8 seconds. The timeout occurred before this feature path and is
not recorded as product success evidence for the new provisional state.
- Red baseline: three new regressions failed against the original implementation—raw
unfinished prose had no progress role, an obsolete concurrent EventSource was not
closed, and replaying chunk index `0` produced duplicated text. A fourth targeted red
test caught and prevented a retry regression introduced by the first connection
fence implementation.
- Focused conversation/store tests: `25 passed`.
- Adjacent ImageCanvas, Store, Fine Tune, youth summary/projection/copy tests:
`6 files / 49 passed`.
- TypeScript `tsc --noEmit`: passed.
- Changed-file ESLint: passed.
- Production Renderer/Main/Preload/utility Vite build: passed; only existing
browserslist, dynamic-import, and chunk-size warnings were emitted.
- Electron/Playwright AI Design V2 flow: `2 passed`, covering desktop/mobile layout,
conversation, optional adjustments, Quote, and generation confirmation.
- `git diff --check`: passed before task-document completion.
## Follow-ups
- Integrate the completed source commit onto client `main` after the existing root
integration owner is safely released or explicitly recovered. Do not force-release
it or modify its three adopted foreign task records without separate authority.
## Promotion Candidates
- Target: `.project-docs/30-worklog/current-state.md`, canonical AI Design UX wording,
and the AI Design stream reconciliation rule in architecture/domain memory.
- Proposal: record that `design.assistant.delta` is unfinished progress, never a
committed conversation turn. Renderer may show an explicit progress/uncertainty
status but must not render its raw prose as a chat reply; only canonical persisted
turns enter the timeline. A locally submitted chat command may appear immediately as
a clearly provisional user bubble by projecting its existing pending operation; it
must disappear when the canonical turn arrives and must not become a second semantic
message store. Concurrent/replayed stream delivery must converge by connection
generation and `chunkIndex`.
- Evidence: the user screenshot, four red-before/green-after regressions, 49 adjacent
tests, typecheck, scoped lint, production build, and 2/2 Electron E2E.
- Future impact: later streaming polish must preserve the distinction between transient
reasoning/progress, authoritative Specification projection, and committed dialogue.
- Semantic conflicts: none; this sharpens the conversation-primary behavior already
integrated while preserving ADR-007.
- Human confirmation: the user explicitly identified the chat-bubble presentation as
misleading; no additional product-direction decision is required.
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Clock3, Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -47,22 +47,44 @@ 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 pendingChatMessages = Object.values(pendingOperations).flatMap((operation) => {
const command = operation.command;
if (command.kind !== 'apply_input'
|| command.workspaceId !== workspace.workspace.workspaceId
|| command.input.kind !== 'chat') {
return [];
}
return [{
operationId: operation.id,
message: command.input.message,
status: operation.status,
}];
});
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(
const submittingDesignInput = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
);
const projectedDraft = chatDraft.trim() !== ''
&& pendingChatMessages.some((message) => message.message === chatDraft.trim());
const composerDraft = projectedDraft ? '' : chatDraft;
const pendingChatKey = pendingChatMessages
.map((message) => `${message.operationId}:${message.status}`)
.join('|');
useEffect(() => {
const scrollAnchor = scrollAnchorRef.current;
if (typeof scrollAnchor?.scrollIntoView === 'function') {
scrollAnchor.scrollIntoView({ block: 'end' });
}
}, [streams, workspace.turns.length]);
}, [hasAssistantProgress, pendingChatKey, workspace.turns.length]);
const submit = () => {
if (!chatDraft.trim() || submittingChat) return;
if (!composerDraft.trim() || submittingDesignInput) return;
void sendChat().catch((error) => {
toast.error(chatFailureMessage(error));
});
@@ -84,7 +106,9 @@ 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
&& pendingChatMessages.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,16 +149,49 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
</div>
))}
{streams.map(([operationId, text]) => (
{pendingChatMessages.map((message) => (
<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"
key={message.operationId}
data-testid={`pending-design-chat-${message.operationId}`}
aria-live="polite"
aria-atomic="true"
className="ml-auto max-w-[88%]"
>
{text}
<span className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle" />
<div className="rounded-2xl rounded-br-md bg-brand/90 px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
{message.message}
</div>
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
{message.status === 'submitting'
? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
: <Clock3 className="h-3 w-3" aria-hidden="true" />}
<span>{message.status === 'submitting' ? '发送中' : '正在确认'}</span>
</div>
</div>
))}
{hasAssistantProgress && (
<div
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"
>
<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} />
</div>
@@ -142,14 +199,14 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
<footer className="border-t border-border/70 bg-background p-4">
<div className={cn(
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10',
submittingChat && 'opacity-80',
submittingDesignInput && 'opacity-80',
)}>
<Textarea
id="design-chat-composer"
aria-label="告诉 AI 你想创作什么"
value={chatDraft}
value={composerDraft}
rows={3}
disabled={submittingChat}
disabled={submittingDesignInput}
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……"
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => setChatDraft(event.currentTarget.value)}
@@ -166,11 +223,11 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
type="button"
size="icon"
className="h-11 w-11 rounded-xl"
disabled={!chatDraft.trim() || submittingChat}
disabled={!composerDraft.trim() || submittingDesignInput}
aria-label="发送消息"
onClick={submit}
>
{submittingChat
{submittingDesignInput
? <Loader2 className="h-4 w-4 animate-spin" />
: <Send className="h-4 w-4" />}
</Button>
+23 -3
View File
@@ -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',
+110 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { ImageCanvas } from '@/pages/ImageCanvas';
@@ -93,6 +93,115 @@ describe('youth AI Design Canvas page', () => {
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('presents unfinished AI output as progress instead of a chat reply', () => {
prepareWorkspace();
const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。';
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-1': unfinishedDraft },
pendingOperations: {
'operation-chat-1': {
id: 'operation-chat-1',
label: '发送创作想法',
command: {
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-chat-1',
input: { kind: 'chat', message: '做一张社团活动海报' },
},
status: 'submitting',
error: null,
clearDraftPaths: [],
clearChatDraft: true,
},
},
});
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
expect(within(conversation).getByRole('status')).toHaveTextContent('AI 正在整理你的想法');
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument();
});
it('shows a submitted message immediately and replaces it with the canonical turn', async () => {
const message = '画一只在月球踢球的熊猫';
const { workspace } = prepareWorkspace({ turns: [] });
useImageWorkspaceStore.setState({
chatDraft: message,
pendingOperations: {
'operation-chat-1': {
id: 'operation-chat-1',
label: '发送消息',
command: {
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-chat-1',
input: { kind: 'chat', message },
},
status: 'submitting',
error: null,
clearDraftPaths: [],
clearChatDraft: true,
},
},
});
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
expect(within(pendingMessage).getByText(message)).toBeInTheDocument();
expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument();
expect(within(conversation).queryByRole('heading', { name: '先把脑中的画面说出来' }))
.not.toBeInTheDocument();
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
.toHaveValue('');
act(() => {
useImageWorkspaceStore.setState((state) => ({
pendingOperations: {
...state.pendingOperations,
'operation-chat-1': {
...state.pendingOperations['operation-chat-1']!,
status: 'unknown',
},
},
}));
});
expect(within(pendingMessage).getByText('正在确认')).toBeInTheDocument();
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' }))
.toHaveValue('');
act(() => {
useImageWorkspaceStore.setState({
workspace: {
...workspace,
turns: [{
turnId: 'turn-2',
rawTurnSequence: 2,
userMessage: message,
assistantMessage: '听起来很有趣!你希望画面更像漫画,还是更像电影?',
}],
},
chatDraft: '',
pendingOperations: {},
});
});
await waitFor(() => {
expect(within(conversation).getAllByText(message)).toHaveLength(1);
});
expect(within(conversation).queryByTestId('pending-design-chat-operation-chat-1'))
.not.toBeInTheDocument();
expect(within(conversation).queryByText('发送中')).not.toBeInTheDocument();
expect(within(conversation).getByText(/更像漫画,还是更像电影/)).toBeInTheDocument();
});
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({
+133
View File
@@ -74,6 +74,14 @@ class FakeEventSource {
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function loadedStore(eventSource = new FakeEventSource()) {
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
await useImageWorkspaceStore.getState().load();
@@ -110,6 +118,87 @@ describe('V2 Living Form store', () => {
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
});
it('closes an obsolete event source when a replacement connection wins the race', async () => {
const workspace = designWorkspaceFixture();
useImageWorkspaceStore.setState({
status: 'ready',
activeWorkspaceId: workspace.workspace.workspaceId,
workspace,
});
const firstOpen = deferred<EventSource>();
const secondOpen = deferred<EventSource>();
const firstSource = new FakeEventSource();
const secondSource = new FakeEventSource();
openEventsMock
.mockReturnValueOnce(firstOpen.promise)
.mockReturnValueOnce(secondOpen.promise);
useImageWorkspaceStore.getState().connectEvents();
useImageWorkspaceStore.getState().disconnectEvents();
useImageWorkspaceStore.getState().connectEvents();
firstOpen.resolve(firstSource as unknown as EventSource);
await Promise.resolve();
secondOpen.resolve(secondSource as unknown as EventSource);
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
expect(firstSource.close).toHaveBeenCalledOnce();
expect(secondSource.close).not.toHaveBeenCalled();
});
it('allows a new event connection after opening the previous one failed', async () => {
const workspace = designWorkspaceFixture();
const recoveredSource = new FakeEventSource();
useImageWorkspaceStore.setState({
status: 'ready',
activeWorkspaceId: workspace.workspace.workspaceId,
workspace,
});
openEventsMock
.mockRejectedValueOnce(new Error('temporary event connection failure'))
.mockResolvedValueOnce(recoveredSource as unknown as EventSource);
useImageWorkspaceStore.getState().connectEvents();
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded'));
useImageWorkspaceStore.getState().connectEvents();
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
expect(recoveredSource.close).not.toHaveBeenCalled();
});
it('uses assistant chunk indexes to ignore replayed deltas', async () => {
const source = await loadedStore();
const baseEvent = {
type: 'design.assistant.delta',
workspaceId: 'workspace-1',
directionId: 'direction-1',
clientOperationId: 'operation-chat-1',
directionRevision: 4,
} as const;
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:7',
chunkIndex: 0,
delta: '收到',
});
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:7-replayed',
chunkIndex: 0,
delta: '收到',
});
source.emit('design.assistant.delta', {
...baseEvent,
id: 'session-1:8',
chunkIndex: 1,
delta: ',正在整理',
});
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
'operation-chat-1': '收到,正在整理',
});
});
it('keeps a field draft separate until the direct edit succeeds', async () => {
await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');
@@ -134,6 +223,50 @@ describe('V2 Living Form store', () => {
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});
it('publishes a pending chat operation before the server result settles', async () => {
await loadedStore();
const message = '画一只在月球踢球的熊猫';
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
submitCommandMock.mockReturnValueOnce(submission.promise);
useImageWorkspaceStore.getState().setChatDraft(message);
const sendPromise = useImageWorkspaceStore.getState().sendChat();
expect(useImageWorkspaceStore.getState()).toMatchObject({
chatDraft: message,
pendingOperations: {
'operation-1': {
id: 'operation-1',
status: 'submitting',
clearChatDraft: true,
command: {
kind: 'apply_input',
workspaceId: 'workspace-1',
clientOperationId: 'operation-1',
input: { kind: 'chat', message },
},
},
},
});
const workspace = designWorkspaceFixture({
turns: [{
turnId: 'turn-2',
rawTurnSequence: 2,
userMessage: message,
assistantMessage: '你希望画面更像漫画,还是更像电影?',
}],
});
submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace });
await sendPromise;
expect(useImageWorkspaceStore.getState()).toMatchObject({
workspace,
chatDraft: '',
pendingOperations: {},
});
});
it('settles a definitive reasoner failure without losing the chat draft', async () => {
const source = await loadedStore();
useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人');