fix(design): show submitted messages immediately
Some checks failed
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

This commit is contained in:
2026-09-04 08:38:13 +08:00
parent c6b0490d7f
commit 403236115b
4 changed files with 208 additions and 13 deletions

View File

@@ -18,6 +18,10 @@
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.
@@ -37,6 +41,8 @@
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
@@ -60,6 +66,17 @@
## 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
@@ -79,6 +96,20 @@
## 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
@@ -108,8 +139,11 @@
- 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. Concurrent/replayed stream delivery must converge by
connection generation and `chunkIndex`.
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

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-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,6 +47,19 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
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);
@@ -54,18 +67,24 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
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' });
}
}, [hasAssistantProgress, 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));
});
@@ -87,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 && !hasAssistantProgress && (
{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" />
@@ -128,6 +149,26 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
</div>
))}
{pendingChatMessages.map((message) => (
<div
key={message.operationId}
data-testid={`pending-design-chat-${message.operationId}`}
aria-live="polite"
aria-atomic="true"
className="ml-auto max-w-[88%]"
>
<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"
@@ -158,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)}
@@ -182,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>

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';
@@ -126,6 +126,82 @@ describe('youth AI Design Canvas page', () => {
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({

View File

@@ -223,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('画一只会做饭的机器人');