From 23f96a523eb37d8397bb3766ce95a59d56e59225 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 4 Sep 2026 11:37:03 +0800 Subject: [PATCH] fix(design): restore streamed replies and quote handoff --- .../20260904-fix-design-stream-4f7b91c2.md | 149 ++++++++++++++++++ .../ImageCanvas/DesignConversationPane.tsx | 85 +++++++++- .../ImageCanvas/DesignProductionPanel.tsx | 6 +- src/pages/ImageCanvas/YouthCreationCard.tsx | 13 +- src/pages/ImageCanvas/index.tsx | 19 ++- tests/e2e/image-workspace-v2.spec.ts | 3 +- tests/unit/image-canvas-page.test.tsx | 71 ++++++++- tests/unit/image-workspace-store.test.ts | 24 ++- tests/unit/youth-creation-card.test.tsx | 11 +- 9 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260904-fix-design-stream-4f7b91c2.md diff --git a/.project-docs/30-worklog/tasks/20260904-fix-design-stream-4f7b91c2.md b/.project-docs/30-worklog/tasks/20260904-fix-design-stream-4f7b91c2.md new file mode 100644 index 0000000..8af3246 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260904-fix-design-stream-4f7b91c2.md @@ -0,0 +1,149 @@ +# Task: Restore AI Design reply streaming and generation CTA + +## Identity + +- Task ID: 20260904-fix-design-stream-4f7b91c2 +- Mode: Feature +- Branch: codex/20260904-diagnose-design-stream-6a4e9c21-diagnose-design-stream +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260904-diagnose-design-stream-6a4e9c21 +- Base commit: 229b1b1ce39b7f1541a93ea3c980ab82b6d6266c +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Restore a visible, explicitly unfinished assistant reply in the AI Design + conversation from the existing `design.assistant.delta` / `assistantStreams` + transport, and remove it when the canonical assistant turn settles. +- Add a youth-readable inline “看看制作方案” action at the end of a ready + conversation. Reuse the existing Quote request and reveal the offered Quote after + success; preserve the separate explicit Design Point confirmation before Task + creation. +- Own only `DesignConversationPane`, the ImageCanvas composition, the smallest + necessary Quote/Card/store settlement seams, focused Renderer/Electron tests, and + this task record. +- Do not modify the Works Square server, reasoner contract, database/API DTOs, + billing semantics, Plugin navigation, packaging, publication, or the three foreign + untracked task records in the client root. + +## Intent And Constraints + +- The user confirmed the preferred interaction: the conversation should paint the + assistant's reply incrementally again, but must not restore the removed generic + “AI 正在整理你的想法” progress banner or present the right-side Specification + summary as a second completed chat reply. +- A streamed fragment is provisional presentation only. `workspace.turns` remains + canonical history; canonical settlement replaces the provisional bubble and must + not leave duplicate or permanently pulsing text. +- The existing transport is post-validation chunk projection, not provider-native + time-to-first-token streaming. This task restores honest incremental painting from + that supported contract and does not broaden the server work. +- Readiness may invite the user to inspect a Quote, but ordinary chat never authorizes + a paid generation Task. Quote request, offered Quote, explicit Quote-ID confirmation, + and Task projection remain separate boundaries. +- Preserve provisional user-message immediacy, stable operation identity, unknown + outcome recovery, and youth-readable failure copy. + +## Project Context Loaded + +- Concurrent Task Gate passed for task `20260904-fix-design-stream-4f7b91c2`, mode + Feature, on branch + `codex/20260904-diagnose-design-stream-6a4e9c21-diagnose-design-stream` in isolated + worktree + `D:\Datas\OthersProjects\.codex-worktrees\makelore\20260904-diagnose-design-stream-6a4e9c21` + at exact base `229b1b1ce39b7f1541a93ea3c980ab82b6d6266c`; ownership is claimed by + `codex-root`. +- Loaded the startup memory set, current state, ADR-007, system overview, business + rules, success criteria, evidence/reflection/commitment indexes, and the completed + diagnosis task that reproduced the missing streamed presentation while proving the + Quote-confirm-Task source path still works. +- Relevant peer tasks are historical/stale or already integrated. This scope does not + overlap the Plugin navigation planner, the older Enter-submission test task, or the + three foreign untracked root records. +- The current canonical docs intentionally keep raw deltas out of completed history + and prohibit a separate progress banner. The user's explicit product decision adds + a narrower distinction: a delta may be shown as one visibly unfinished assistant + bubble that is replaced by the canonical turn. Record this as a promotion candidate + rather than editing canonical docs in Feature mode. +- Planning Gate result: Passed. + +## Plan + +1. Add failing Renderer regressions for provisional assistant visibility, + canonical replacement, and the ready-conversation Quote action. +2. Implement the minimal presentation and settlement changes, then make both the + conversation CTA and right-side CTA reveal the offered Quote. +3. Run focused unit/Electron checks plus typecheck, scoped lint, build, diff, and + project-document gates; document the exact outcome and remaining architectural + limitation. + +## Outcome + +- Restored the existing `assistantStreams` presentation as one assistant-shaped, + visibly unfinished reply beneath its matching pending chat operation. New chunks + update that same bubble and its pulse cursor; the removed generic “AI 正在整理你的 + 想法” status banner remains absent. +- Kept provisional output out of canonical history. Per-command success or definitive + failure clears that operation's stream, and the conversation only renders a stream + while its original pending chat identity still exists, so the later canonical + `workspace.turns` reply replaces rather than duplicates it. Direction events do not + globally clear unrelated streams because they expose no client operation identity; + unknown outcomes retain their original pending identity and partial reply. +- Added an inline ready-state handoff at the end of the conversation. It explains that + the next step only calculates Design Point cost and requests a Quote; it never calls + generation confirmation. +- After a Quote request succeeds from either the conversation action or the right-side + Creation Card, desktop scrolls the offered Quote into view and mobile opens the + Current Idea/Works sheet before locating it. The existing explicit + “花 N 设计点开始制作” Quote-ID confirmation remains the only Task-creation action. +- No Works Square server, reasoner, database/API DTO, billing, Plugin navigation, + packaging, or publication code changed. + +## Verification + +- Red phase: the focused Renderer suite failed exactly because no streamed assistant + bubble and no conversation Quote action existed (2 failed / 26 passed). +- Focused final suite: + `pnpm exec vitest run tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-store.test.ts tests/unit/youth-creation-card.test.tsx` + -> 3 files / 35 tests passed. +- The focused regressions include two simultaneous stream identities, an unknown Quote + that disables both Quote entry points and retries the original operation, and the + mobile inline action opening the Sheet with its offered Quote. +- Electron production flow: + `node ./node_modules/@playwright/test/cli.js test tests/e2e/image-workspace-v2.spec.ts -g "keeps conversation and youth-friendly direct edits"` + -> 1 passed; it used the new conversation action, verified the Quote was in the + viewport, explicitly confirmed 12 Design Points, and observed the queued Task. +- `pnpm exec tsc --noEmit`, scoped ESLint for every changed TypeScript/test file, + `pnpm run build:vite`, and `git diff --check` passed. Vite reported only existing + Browserslist, chunk-size, and dynamic/static-import warnings. +- Full ordinary suite reached 224 passing files / 1,873 passing tests / 2 declared + skips. Its only failure was an unrelated real Pi subprocess latency assertion + (2,310 ms versus a 2,000 ms threshold); the exact process test passed 1/1 when + immediately rerun alone. The separately gated pressure test passed 1/1. +- Independent read-only review found and then verified fixes for the two concrete P2 + gaps above (global stream cleanup and duplicate unknown Quote submission); final + verdict: PASS with no remaining P1/P2 blocker. + +## Follow-ups + +- The current server does not provide provider-native time-to-first-token streaming: + it validates the complete structured reasoner response, then projects the final + assistant message in short chunks. Genuine earlier first-visible-text would require + a separately designed server conversational-stream contract; this client task does + not pretend otherwise. + +## Promotion Candidates + +- Target canonical documents: `.project-docs/20-architecture/system-overview.md` and + `.project-docs/40-domain/business-rules.md`. Proposal: distinguish completed + canonical assistant turns, a single provisional assistant bubble backed by + `design.assistant.delta`, and the prohibited generic progress/summary banner; also + record that ready conversation UI may request/reveal a Quote but only explicit + Quote-ID confirmation can create a paid Task. Evidence: the red-to-green Renderer + regressions and passing Electron Quote-confirm-Task flow in this task. Future impact: + prevents another presentation cleanup from accidentally removing supported + incremental feedback or collapsing Quote review into generation authorization. + Semantic conflict: current wording says raw delta must not be displayed as a + completed reply and forbids a separate progress bar; the new behavior preserves + both constraints by displaying exactly one explicitly unfinished reply. Human + confirmation was required and was supplied by the user before implementation. diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index edd8b23..501286b 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -1,5 +1,12 @@ import { useEffect, useRef } from 'react'; -import { Clock3, Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react'; +import { + Clock3, + Loader2, + MessageSquareText, + Send, + Sparkles, + WandSparkles, +} from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; @@ -40,10 +47,20 @@ function chatFailureMessage(error: unknown): string { return '暂时没能确认这条消息的处理结果,内容还在输入框里'; } -export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) { +export function DesignConversationPane({ + workspace, + canRequestQuote, + onQuoteOffered, +}: { + workspace: DesignWorkspace; + canRequestQuote: boolean; + onQuoteOffered: () => void; +}) { const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); const sendChat = useImageWorkspaceStore((state) => state.sendChat); + const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); + const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const scrollAnchorRef = useRef(null); const pendingChatMessages = Object.values(pendingOperations).flatMap((operation) => { @@ -62,18 +79,29 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa const submittingDesignInput = Object.values(pendingOperations).some( (operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input', ); + const quoteRequestPending = Object.values(pendingOperations).some( + (operation) => operation.command.kind === 'request_quote' + && operation.command.workspaceId === workspace.workspace.workspaceId, + ); + const streamingReplies = pendingChatMessages.flatMap((message) => { + const text = assistantStreams[message.operationId]?.trim(); + return text ? [{ ...message, text }] : []; + }); 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('|'); + const streamingReplyKey = streamingReplies + .map((reply) => `${reply.operationId}:${reply.text}`) + .join('|'); useEffect(() => { const scrollAnchor = scrollAnchorRef.current; if (typeof scrollAnchor?.scrollIntoView === 'function') { scrollAnchor.scrollIntoView({ block: 'end' }); } - }, [pendingChatKey, workspace.turns.length]); + }, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]); const submit = () => { if (!composerDraft.trim() || submittingDesignInput) return; @@ -82,6 +110,15 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa }); }; + const prepareQuote = () => { + if (!canRequestQuote || quoteRequestPending || pendingChatMessages.length > 0) return; + void requestQuote() + .then(onQuoteOffered) + .catch(() => { + toast.error('制作方案还没准备好,请稍后再试'); + }); + }; + return (
))} + {streamingReplies.map((reply) => ( +
+ {reply.text} +
+ ))} + + {canRequestQuote && pendingChatMessages.length === 0 && ( +
+
+

想法已经清楚了

+

+ 先看看需要多少设计点,确认后才会开始制作。 +

+
+ +
+ )} +
diff --git a/src/pages/ImageCanvas/DesignProductionPanel.tsx b/src/pages/ImageCanvas/DesignProductionPanel.tsx index 3c19e96..abca885 100644 --- a/src/pages/ImageCanvas/DesignProductionPanel.tsx +++ b/src/pages/ImageCanvas/DesignProductionPanel.tsx @@ -177,7 +177,11 @@ export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace }) } return ( -
+
diff --git a/src/pages/ImageCanvas/YouthCreationCard.tsx b/src/pages/ImageCanvas/YouthCreationCard.tsx index b8c8e53..85b8672 100644 --- a/src/pages/ImageCanvas/YouthCreationCard.tsx +++ b/src/pages/ImageCanvas/YouthCreationCard.tsx @@ -33,6 +33,7 @@ export type YouthCreationCardProps = { generationAvailable: boolean; onOpenFineTune: () => void; onFocusComposer: () => void; + onQuoteOffered?: () => void; }; function formatDuration(seconds: number): string { @@ -335,19 +336,23 @@ export function YouthCreationCard({ generationAvailable, onOpenFineTune, onFocusComposer, + onQuoteOffered, }: YouthCreationCardProps) { const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const model = projectYouthCreationCard(workspace, { quoteBlockers }); const requesting = Object.values(pendingOperations).some( - (operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote', + (operation) => operation.command.kind === 'request_quote' + && operation.command.workspaceId === workspace.workspace.workspaceId, ); const request = () => { if (!generationAvailable || requesting || !model.canRequestQuote) return; - void requestQuote().catch(() => { - toast.error('制作方案还没准备好,请稍后再试'); - }); + void requestQuote() + .then(() => onQuoteOffered?.()) + .catch(() => { + toast.error('制作方案还没准备好,请稍后再试'); + }); }; const hasMoreDetails = model.mustInclude.length > 0 || model.mustPreserve.length > 0 diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index f259a61..4731569 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -185,6 +185,15 @@ export function ImageCanvas() { setCreationCardOpen(false); setFineTuneOpen(true); }; + const revealOfferedQuote = () => { + if (!desktopLayout) setCreationCardOpen(true); + globalThis.setTimeout(() => { + const quote = document.getElementById('design-offered-quote'); + if (typeof quote?.scrollIntoView === 'function') { + quote.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, 0); + }; const productionContent = (
@@ -270,7 +279,13 @@ export function ImageCanvas() {
- +
{desktopLayout && ( @@ -286,6 +301,7 @@ export function ImageCanvas() { generationAvailable={generationAvailable} onOpenFineTune={openFineTune} onFocusComposer={focusComposer} + onQuoteOffered={revealOfferedQuote} /> {productionContent}
@@ -309,6 +325,7 @@ export function ImageCanvas() { generationAvailable={generationAvailable} onOpenFineTune={openFineTune} onFocusComposer={focusComposer} + onQuoteOffered={revealOfferedQuote} /> {productionContent}
diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 4bd5277..3f61ae4 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -462,8 +462,9 @@ test.describe('AI Design V2 workspace', () => { await expect(page.getByTestId('image-workspace-conversation')) .toContainText('记住了:主体更简洁,也会为标题留出空间。'); - await page.getByRole('button', { name: '看看制作方案' }).click(); + await page.getByRole('button', { name: '下一步:看看制作方案', exact: true }).click(); await expect(page.getByRole('heading', { name: '制作方案准备好了' })).toBeVisible(); + await expect(page.getByTestId('design-offered-quote')).toBeInViewport(); await expect(page.getByText('开始后会按现在的想法制作;如果再改想法,需要重新准备方案。')).toBeVisible(); await expect(page.getByLabel(/Prompt|提示词/i)).toHaveCount(0); await expect(page.getByText(/规格版本|编译器|生成方式|不可变/)).toHaveCount(0); diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index 9049664..dc3bdae 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -93,11 +93,12 @@ describe('youth AI Design Canvas page', () => { expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument(); }); - it('keeps unfinished AI output out of the conversation while send status stays visible', () => { - prepareWorkspace(); - const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。'; + it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => { + const { workspace } = prepareWorkspace(); + const firstChunk = '收到,我们要制作'; + const unfinishedDraft = `${firstChunk}一张社团活动海报。`; useImageWorkspaceStore.setState({ - assistantStreams: { 'operation-chat-1': unfinishedDraft }, + assistantStreams: { 'operation-chat-1': firstChunk }, pendingOperations: { 'operation-chat-1': { id: 'operation-chat-1', @@ -124,10 +125,66 @@ describe('youth AI Design Canvas page', () => { const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); expect(within(pendingMessage).getByText('做一张社团活动海报')).toBeInTheDocument(); expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument(); + expect(within(conversation).queryByRole('button', { name: '下一步:看看制作方案' })) + .not.toBeInTheDocument(); expect(within(conversation).queryByTestId('design-assistant-progress')).not.toBeInTheDocument(); expect(within(conversation).queryByText('AI 正在整理你的想法')).not.toBeInTheDocument(); expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument(); - expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument(); + const streamingReply = within(conversation) + .getByTestId('streaming-design-assistant-operation-chat-1'); + expect(streamingReply).toHaveTextContent(firstChunk); + + act(() => { + useImageWorkspaceStore.setState({ + assistantStreams: { 'operation-chat-1': unfinishedDraft }, + }); + }); + expect(streamingReply).toHaveTextContent(unfinishedDraft); + + act(() => { + useImageWorkspaceStore.setState({ + workspace: { + ...workspace, + turns: [...workspace.turns, { + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: '做一张社团活动海报', + assistantMessage: unfinishedDraft, + }], + }, + pendingOperations: {}, + }); + }); + + await waitFor(() => { + expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1')) + .not.toBeInTheDocument(); + }); + expect(within(conversation).getAllByText(unfinishedDraft)).toHaveLength(1); + expect(within(conversation).getByRole('button', { name: '下一步:看看制作方案' })) + .toBeInTheDocument(); + }); + + it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => { + const { actions } = prepareWorkspace(); + const quotedWorkspace = designWorkspaceFixture({ + form: designFormFixture({ activeQuotes: [designQuoteFixture()] }), + }); + actions.requestQuote.mockImplementationOnce(async () => { + useImageWorkspaceStore.setState({ workspace: quotedWorkspace }); + return quotedWorkspace; + }); + render(); + + const conversation = screen.getByTestId('image-workspace-conversation'); + fireEvent.click(within(conversation).getByRole('button', { name: '下一步:看看制作方案' })); + + expect(actions.requestQuote).toHaveBeenCalledOnce(); + expect(actions.confirmGeneration).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument(); + }); + expect(screen.getByTestId('design-offered-quote')).toBeInTheDocument(); }); it('shows a submitted message immediately and replaces it with the canonical turn', async () => { @@ -338,9 +395,13 @@ describe('youth AI Design Canvas page', () => { }); render(); + expect(within(screen.getByTestId('image-workspace-conversation')) + .getByRole('button', { name: '正在准备方案' })).toBeDisabled(); openMobileCreationCard(); expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument(); expect(screen.getByText(/不会重复制作或重复扣费/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '看看制作方案', exact: true })).toBeDisabled(); + expect(actions.requestQuote).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '查看原来的结果' })); expect(actions.retryOperation).toHaveBeenCalledWith('operation-1'); }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 2d29916..18aea60 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -523,6 +523,25 @@ describe('V2 Living Form store', () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '本地草稿'); useImageWorkspaceStore.setState({ + assistantStreams: { 'operation-chat-unknown': '这段回复还没确认' }, + pendingOperations: { + 'operation-chat-unknown': { + id: 'operation-chat-unknown', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-unknown', + input: { kind: 'chat', message: '保留这条未知结果' }, + }, + status: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + }, + }, quoteBlockers: [{ code: 'missing_choice', message: '还需要选择作品形状', @@ -573,9 +592,12 @@ describe('V2 Living Form store', () => { }), }); + expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ + 'operation-chat-unknown': '这段回复还没确认', + 'operation-chat-1': '正在整理', + }); expect(useImageWorkspaceStore.getState()).toMatchObject({ lastEventId: 'session-1:8', - assistantStreams: { 'operation-chat-1': '正在整理' }, fieldDrafts: { 'intent.purpose': '本地草稿' }, quoteBlockers: [], workspace: { form: { directionRevision: 5, specificationRevision: 4 } }, diff --git a/tests/unit/youth-creation-card.test.tsx b/tests/unit/youth-creation-card.test.tsx index 839f201..b603bfe 100644 --- a/tests/unit/youth-creation-card.test.tsx +++ b/tests/unit/youth-creation-card.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard'; import { DesignTaskPanel } from '@/pages/ImageCanvas/DesignProductionPanel'; @@ -21,12 +21,14 @@ function renderCard( quoteBlockers?: DesignCompilationIssue[]; onOpenFineTune?: () => void; onFocusComposer?: () => void; + onQuoteOffered?: () => void; } = {}, ) { const requestQuote = vi.fn().mockResolvedValue(workspace); const resolveDecisionPrompt = vi.fn().mockResolvedValue(workspace); const onOpenFineTune = options.onOpenFineTune ?? vi.fn(); const onFocusComposer = options.onFocusComposer ?? vi.fn(); + const onQuoteOffered = options.onQuoteOffered ?? vi.fn(); useImageWorkspaceStore.setState({ workspace, quoteBlockers: [], @@ -41,6 +43,7 @@ function renderCard( generationAvailable={options.generationAvailable ?? true} onOpenFineTune={onOpenFineTune} onFocusComposer={onFocusComposer} + onQuoteOffered={onQuoteOffered} />, ); return { @@ -49,6 +52,7 @@ function renderCard( resolveDecisionPrompt, onOpenFineTune, onFocusComposer, + onQuoteOffered, }; } @@ -97,8 +101,8 @@ describe('YouthCreationCard', () => { vi.clearAllMocks(); }); - it('shows a plain creation summary and offers a youth-facing ready action', () => { - const { requestQuote } = renderCard(); + it('shows a plain creation summary and reveals the offered quote after requesting it', async () => { + const { onQuoteOffered, requestQuote } = renderCard(); expect(screen.getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument(); expect(screen.getByText('会跟着聊天自动更新,不需要逐项填写。')).toBeInTheDocument(); @@ -112,6 +116,7 @@ describe('YouthCreationCard', () => { fireEvent.click(screen.getByRole('button', { name: '看看制作方案' })); expect(requestQuote).toHaveBeenCalledOnce(); + await waitFor(() => expect(onQuoteOffered).toHaveBeenCalledOnce()); }); it('does not surface legacy decision prompts as a form', () => {