fix(design): separate progress from chat replies

This commit is contained in:
2026-09-04 08:22:32 +08:00
parent 945b40de11
commit c6b0490d7f
5 changed files with 293 additions and 15 deletions

View File

@@ -0,0 +1,120 @@
# 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.
- 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.
## 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
- `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
- 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. 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.

View File

@@ -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} />

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',

View File

@@ -93,6 +93,39 @@ 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('invites a free description and does not turn a legacy decision prompt into a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({

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');