fix(design): restore streamed replies and quote handoff

This commit is contained in:
2026-09-04 11:37:03 +08:00
parent 229b1b1ce3
commit 23f96a523e
9 changed files with 362 additions and 19 deletions

View File

@@ -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.

View File

@@ -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<HTMLDivElement>(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 (
<section
data-testid="image-workspace-conversation"
@@ -160,6 +197,48 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
</div>
))}
{streamingReplies.map((reply) => (
<div
key={reply.operationId}
data-testid={`streaming-design-assistant-${reply.operationId}`}
role="status"
aria-live="polite"
aria-atomic="true"
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"
>
{reply.text}
<span
aria-hidden="true"
className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle"
/>
</div>
))}
{canRequestQuote && pendingChatMessages.length === 0 && (
<div
data-testid="design-conversation-quote-action"
className="flex flex-col gap-3 rounded-2xl border border-brand/20 bg-brand/[0.045] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
className="min-h-11 shrink-0 rounded-xl"
disabled={quoteRequestPending}
onClick={prepareQuote}
>
{quoteRequestPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
: <WandSparkles className="mr-2 h-4 w-4" aria-hidden="true" />}
{quoteRequestPending ? '正在准备方案' : '下一步:看看制作方案'}
</Button>
</div>
)}
</div>
<div ref={scrollAnchorRef} />
</div>

View File

@@ -177,7 +177,11 @@ export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace })
}
return (
<section className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm">
<section
id="design-offered-quote"
data-testid="design-offered-quote"
className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">

View File

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

View File

@@ -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 = (
<div className="space-y-5">
<DesignPendingOperationsPanel />
@@ -270,7 +279,13 @@ export function ImageCanvas() {
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
<div className="flex min-h-0 lg:border-r lg:border-border/70">
<DesignConversationPane workspace={workspace} />
<DesignConversationPane
workspace={workspace}
canRequestQuote={generationAvailable
&& cardModel.phase === 'ready'
&& cardModel.canRequestQuote}
onQuoteOffered={revealOfferedQuote}
/>
</div>
{desktopLayout && (
@@ -286,6 +301,7 @@ export function ImageCanvas() {
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>
@@ -309,6 +325,7 @@ export function ImageCanvas() {
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>

View File

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

View File

@@ -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(<ImageCanvas />);
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(<ImageCanvas />);
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');
});

View File

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

View File

@@ -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', () => {