fix(design): restore streamed replies and quote handoff
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user