From c6b0490d7fc74da1bf1c8c21b42c3a33201c6edc Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 4 Sep 2026 08:22:32 +0800 Subject: [PATCH] fix(design): separate progress from chat replies --- ...0904-design-summary-separation-9c4e7a21.md | 120 ++++++++++++++++++ .../ImageCanvas/DesignConversationPane.tsx | 40 ++++-- src/stores/image-workspace.ts | 26 +++- tests/unit/image-canvas-page.test.tsx | 33 +++++ tests/unit/image-workspace-store.test.ts | 89 +++++++++++++ 5 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260904-design-summary-separation-9c4e7a21.md diff --git a/.project-docs/30-worklog/tasks/20260904-design-summary-separation-9c4e7a21.md b/.project-docs/30-worklog/tasks/20260904-design-summary-separation-9c4e7a21.md new file mode 100644 index 0000000..fb61e52 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260904-design-summary-separation-9c4e7a21.md @@ -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. diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 082902b..2422565 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -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(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
- {workspace.turns.length === 0 && streams.length === 0 && ( + {workspace.turns.length === 0 && !hasAssistantProgress && (
@@ -125,15 +128,28 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
))} - {streams.map(([operationId, text]) => ( + {hasAssistantProgress && (
- {text} - + + +
+

+ {hasUnknownAssistantProgress ? '正在确认刚才的整理结果' : 'AI 正在整理你的想法'} +

+

+ {hasUnknownAssistantProgress + ? '结果回来后会显示在对话里,不需要重复发送。' + : '这不是新的回复,完成后会显示下一步问题或建议。'} +

+
- ))} + )}
diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index f3e0a11..3be10cd 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -96,7 +96,9 @@ type ImageWorkspaceState = { let inFlightLoad: Promise | null = null; let activeEventSource: EventSource | null = null; let activeEventWorkspaceId: string | null = null; +let eventConnectionGeneration = 0; let selectionGeneration = 0; +const assistantStreamChunkIndexes = new Map(); 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((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((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((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((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((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((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((set, get) => lastEventId: null, fieldDrafts: {}, chatDraft: '', + assistantStreams: {}, quoteBlockers: [], error: null, }); @@ -520,8 +531,9 @@ export const useImageWorkspaceStore = create((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((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((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((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((set, get) => reset: () => { selectionGeneration += 1; closeEventSource(); + assistantStreamChunkIndexes.clear(); updateBackgroundLease([]); set({ status: 'idle', diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index 30de220..7de5681 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -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(); + + 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({ diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 059094d..225c076 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -74,6 +74,14 @@ class FakeEventSource { } } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + const secondOpen = deferred(); + 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');