From 81b524a02c2cf51304090afa8fb89d27c72c5d2b Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 7 Sep 2026 09:57:21 +0800 Subject: [PATCH 1/3] feat(design): render assistant activity progress --- ...7-design-agent-activity-client-b6d913e4.md | 103 +++ .../image-workspace/works-square-workspace.ts | 54 ++ shared/image-workspace.ts | 28 + .../ImageCanvas/DesignConversationPane.tsx | 197 +++- src/stores/image-workspace.ts | 580 +++++++++++- tests/unit/image-canvas-page.test.tsx | 124 +++ tests/unit/image-workspace-route.test.ts | 13 +- tests/unit/image-workspace-store.test.ts | 855 +++++++++++++++++- .../works-square-design-workspace.test.ts | 192 ++++ 9 files changed, 2066 insertions(+), 80 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md diff --git a/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md b/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md new file mode 100644 index 0000000..d8612d1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md @@ -0,0 +1,103 @@ +# Task: Render AI Design agent activity progress + +## Identity + +- Task ID: 20260907-design-agent-activity-client-b6d913e4 +- Mode: Feature +- Branch: codex/20260907-design-agent-activity-client-b6d913e4-design-agent-activity-client +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260907-design-agent-activity-client-b6d913e4 +- Base commit: da29294979da4ce041edf992b590ee6ea170a44f +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Consume operation-scoped AI Design activity events through the existing Main-owned + Works Square transport. +- Render one transient activity area attached to the optimistic user message, update + meaningful steps in place, and settle it correctly on final reply or terminal error. +- Cover reconnect/replay, transport-unknown, stale-operation, and compact responsive + presentation without duplicating the right-side Design summary. + +## Intent And Constraints + +- User messages remain immediately visible and final assistant replies remain normal + conversation content. +- Activity is presentation state, not canonical Design state or a durable chat bubble. +- Show at most a few youth-safe phase/action/outcome lines; never render raw reasoning, + tool arguments, prompts, provider output, or technical error details. +- Preserve stable operation identity across uncertain transport outcomes and ignore + progress that belongs to another Workspace/direction/operation. + +## Project Context Loaded + +- Project goal: a youth-friendly desktop creation flow with Electron Main as the only + network authority and server-owned Design Specification semantics. +- Current integrated focus: Living Form V2 and immediate optimistic user messages are + integrated; the experimental `050d78d` progress UI is not assumed present. +- Decisions: ADR-007 keeps Renderer drafts temporary and Gateway operations stable; + the activity view may observe events but cannot become another authority. +- Evidence/commitments: real paid Design production smoke remains separate and is not + claimed by this client presentation task. +- Relevant peers: the Enter-key E2E task is test-only; older generic-error tasks have + undefined scope. No semantic conflict was found. +- Likely modules: Main Workspace adapter/DTO mapping, Renderer store event reduction, + Design conversation pane, and focused unit/Electron E2E tests. +- Unknowns to resolve in source: current stream event DTO shape and the best existing + operation-to-optimistic-message association seam. +- Gate result: Passed. + +## Outcome + +- Added a dedicated `AI 处理过程` panel beneath the optimistic user message. Active + steps update in place; completed processing collapses into a compact, user-toggleable + summary while the final assistant reply continues to stream as normal conversation. +- Added Main-owned normalization for the four public progress stages. Main maps stages + to fixed youth-safe copy instead of trusting arbitrary server/provider text. +- Preserved exact operation association across optimistic, streaming, canonical, and + transport-unknown states by recording the pre-command turn sequence and matching the + earliest exact canonical user turn after that boundary. +- Added Gateway command terminal-event handling so accepted commands converge after a + polling disconnect. WebSocket-terminal-first races remain authoritative, stale + connections cannot mutate current state, duplicate chunks advance the replay cursor, + and late results from another Workspace cannot replace the active Workspace. +- Activity remains transient presentation state: it is filtered by Workspace and + direction, never becomes a chat message or right-side Design authority, and contains + no raw model reasoning, prompts, tool arguments, or technical provider output. + +## Verification + +- `pnpm exec vitest run tests/unit/image-workspace-store.test.ts + tests/unit/image-canvas-page.test.tsx + tests/unit/works-square-design-workspace.test.ts + tests/unit/image-workspace-route.test.ts` — 67 passed. +- `pnpm exec tsc --noEmit` — passed. +- Focused ESLint across all eight changed TypeScript/TSX files — passed. +- `pnpm run build:vite` — passed; only existing chunk-size, dynamic-import, and stale + Browserslist-data warnings were reported. +- `git diff --check` — passed. +- Independent client and lifecycle reviews were repeated after the terminal-race, + exact-turn-association, and Workspace A→B→A fixes — PASS; no reachable P1/P2 + remained. + +## Follow-ups + +- Add an explicit snapshot/cursor reseed contract for a Gateway `cursor expired` + response in a separate transport-resilience task. That pre-existing recovery gap is + broader than the AI activity presentation slice. + +## Promotion Candidates + +- Target canonical documents: ADR-007 and the client/Main transport architecture. +- Proposal: document operation-scoped AI activity as transient Renderer state fed by + Main-normalized public Gateway events; generic Gateway terminal events must settle + uncertain writes without changing operation identity. +- Evidence: fixed public stage projection, exact turn association, terminal/poll race + tests, stale-source and cross-Workspace tests, 67 passing focused tests, typecheck, + lint, and production build. +- Future impact: future Agents SDK lifecycle hooks can reuse the same UI seam, while + canonical Design state continues to come only from Workspace snapshots/events. +- Semantic conflicts: none identified; this preserves ADR-007's Main ownership and + temporary Renderer-state boundaries. +- Human confirmation required: yes, during a future integration-mode promotion to + canonical architecture documentation. diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 063a74e..6e0507b 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -4,11 +4,13 @@ import { type DesignAsset, type DesignAssetUploadInput, type DesignAssistantDeltaEvent, + type DesignAssistantProgressEvent, type DesignCapabilities, type DesignChangeSet, type DesignCommandFailureOutcome, type DesignCommandInput, type DesignCommandResult, + type DesignCommandTerminalEvent, type DesignCompilationIssue, type DesignCreateWorkspaceInput, type DesignDecisionPrompt, @@ -67,6 +69,7 @@ type ServerAgentEvent = { sequence: number; runtime: string; type: string; + client_command_id?: unknown; schema_version: number; payload: unknown; }; @@ -92,6 +95,12 @@ const RUN_MAX_POLL_MS = 4_000; const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; const WEBSOCKET_PING_INTERVAL_MS = 20_000; const WEBSOCKET_OPEN = 1; +const PUBLIC_ASSISTANT_PROGRESS_MESSAGES: Record = { + understanding: '正在听懂你刚补充的内容…', + reviewing_context: '正在把它和前面的设计想法放在一起看…', + validating: '正在确认整理结果没有漏掉或弄错…', + composing: '已经整理好,正在准备回复…', +}; function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -419,6 +428,29 @@ function normalizeWorkspaceEvent( || event.sequence < 1) return null; const payload = record(event.payload); const id = `${sessionId}:${event.sequence}`; + if ([ + 'command.completed', + 'command.failed', + 'command.cancelled', + ].includes(event.type)) { + const clientOperationId = stringValue(event.client_command_id, 'Operation ID'); + const outcome = event.type === 'command.completed' + ? 'succeeded' + : event.type === 'command.failed' + ? 'failed' + : 'cancelled'; + const errorCode = outcome === 'failed' + ? stringValue(record(payload.error).code, 'Command error code') + : null; + return { + id, + type: event.type as DesignCommandTerminalEvent['type'], + workspaceId, + clientOperationId, + outcome, + errorCode, + } satisfies DesignCommandTerminalEvent; + } if (event.type === 'design.session.snapshot') { return { id, type: event.type, form: mapForm(payload.form) } satisfies DesignSessionSnapshotEvent; } @@ -435,12 +467,34 @@ function normalizeWorkspaceEvent( delta: stringValue(payload.delta, 'Assistant delta'), } satisfies DesignAssistantDeltaEvent; } + if (event.type === 'design.assistant.progress') { + if (payload.workspace_id !== workspaceId) return null; + const stage = stringValue(payload.stage, 'Assistant progress stage'); + if (![ + 'understanding', + 'reviewing_context', + 'validating', + 'composing', + ].includes(stage)) return null; + return { + id, + type: event.type, + workspaceId, + directionId: stringValue(payload.direction_id, 'Direction ID'), + clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'), + stage: stage as DesignAssistantProgressEvent['stage'], + message: PUBLIC_ASSISTANT_PROGRESS_MESSAGES[ + stage as DesignAssistantProgressEvent['stage'] + ], + } satisfies DesignAssistantProgressEvent; + } if (event.type === 'design.direction.updated') { const form = mapForm(payload.form); if (form.workspaceId !== workspaceId) return null; return { id, type: event.type, + clientOperationId: stringValue(event.client_command_id, 'Operation ID'), replayed: Boolean(payload.replayed), operation: mapInteraction(payload.operation), form, diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 6135fed..08f0748 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -448,9 +448,35 @@ export type DesignAssistantDeltaEvent = { delta: string; }; +export type DesignAssistantProgressStage = + | 'understanding' + | 'reviewing_context' + | 'validating' + | 'composing'; + +export type DesignAssistantProgressEvent = { + id: string; + type: 'design.assistant.progress'; + workspaceId: string; + directionId: string; + clientOperationId: string; + stage: DesignAssistantProgressStage; + message: string; +}; + +export type DesignCommandTerminalEvent = { + id: string; + type: 'command.completed' | 'command.failed' | 'command.cancelled'; + workspaceId: string; + clientOperationId: string; + outcome: 'succeeded' | 'failed' | 'cancelled'; + errorCode: string | null; +}; + export type DesignDirectionUpdatedEvent = { id: string; type: 'design.direction.updated'; + clientOperationId: string; replayed: boolean; operation: DesignInteractionOutcome; form: DesignForm; @@ -480,7 +506,9 @@ export type DesignWorkspaceUpdatedEvent = { export type DesignWorkspaceEvent = | DesignSessionSnapshotEvent + | DesignAssistantProgressEvent | DesignAssistantDeltaEvent + | DesignCommandTerminalEvent | DesignDirectionUpdatedEvent | DesignQuoteBlockedEvent | DesignWorkspaceUpdatedEvent; diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 501286b..63df463 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { + Check, + ChevronDown, Clock3, Loader2, MessageSquareText, @@ -12,7 +14,10 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; -import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import { + type DesignAssistantActivity, + useImageWorkspaceStore, +} from '@/stores/image-workspace'; import type { DesignWorkspace } from '../../../shared/image-workspace'; const STARTER_MESSAGES = [ @@ -21,6 +26,83 @@ const STARTER_MESSAGES = [ '把我的涂鸦变成一段会动的小故事', ]; +function DesignAssistantActivityPanel({ + activity, + operationId, +}: { + activity: DesignAssistantActivity; + operationId: string; +}) { + const [expanded, setExpanded] = useState(activity.status !== 'completed'); + + const summary = activity.status === 'active' + ? `正在进行第 ${activity.steps.length} 个步骤` + : activity.status === 'completed' + ? `完成了 ${activity.steps.length} 个步骤` + : '连接暂时中断,已完成的步骤还在这里'; + + return ( +
+ + + {expanded && ( +
    + {activity.steps.map((step, index) => { + const isCurrent = activity.status === 'active' && index === activity.steps.length - 1; + const isUncertain = activity.status === 'unknown' && index === activity.steps.length - 1; + return ( +
  1. + + {isCurrent + ? + {step.message} +
  2. + ); + })} +
+ )} +
+ ); +} + function chatFailureMessage(error: unknown): string { if (error instanceof ImageWorkspaceApiError) { if (error.commandOutcome === 'unknown') { @@ -60,6 +142,7 @@ export function DesignConversationPane({ const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); const sendChat = useImageWorkspaceStore((state) => state.sendChat); const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); + const assistantActivities = useImageWorkspaceStore((state) => state.assistantActivities); const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const scrollAnchorRef = useRef(null); @@ -77,7 +160,9 @@ export function DesignConversationPane({ }]; }); const submittingDesignInput = Object.values(pendingOperations).some( - (operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input', + (operation) => operation.status === 'submitting' + && operation.command.kind === 'apply_input' + && operation.command.workspaceId === workspace.workspace.workspaceId, ); const quoteRequestPending = Object.values(pendingOperations).some( (operation) => operation.command.kind === 'request_quote' @@ -96,12 +181,28 @@ export function DesignConversationPane({ const streamingReplyKey = streamingReplies .map((reply) => `${reply.operationId}:${reply.text}`) .join('|'); + const visibleActivities = Object.entries(assistantActivities).filter(([, activity]) => ( + activity.workspaceId === workspace.workspace.workspaceId + && activity.directionId === workspace.form.directionId + && activity.steps.length > 0 + )); + const activityByOperationId = new Map(visibleActivities); + const activityByTurnId = new Map( + visibleActivities.flatMap(([operationId, activity]) => ( + activity.turnId ? [[activity.turnId, { operationId, activity }] as const] : [] + )), + ); + const activityKey = visibleActivities + .map(([operationId, activity]) => ( + `${operationId}:${activity.status}:${activity.steps.map((step) => step.stage).join(',')}` + )) + .join('|'); useEffect(() => { const scrollAnchor = scrollAnchorRef.current; if (typeof scrollAnchor?.scrollIntoView === 'function') { scrollAnchor.scrollIntoView({ block: 'end' }); } - }, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]); + }, [activityKey, canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]); const submit = () => { if (!composerDraft.trim() || submittingDesignInput) return; @@ -162,40 +263,64 @@ export function DesignConversationPane({ )}
- {workspace.turns.map((turn) => ( -
- {turn.userMessage && ( -
- {turn.userMessage} -
- )} - {turn.assistantMessage && ( -
- {turn.assistantMessage} -
- )} -
- ))} + {workspace.turns.map((turn) => { + const linkedActivity = activityByTurnId.get(turn.turnId); + return ( +
+ {turn.userMessage && ( +
+ {turn.userMessage} +
+ )} + {linkedActivity && ( + + )} + {turn.assistantMessage && ( +
+ {turn.assistantMessage} +
+ )} +
+ ); + })} - {pendingChatMessages.map((message) => ( -
-
- {message.message} + {pendingChatMessages.map((message) => { + const linkedActivity = activityByOperationId.get(message.operationId); + return ( +
+
+
+ {message.message} +
+
+ {message.status === 'submitting' + ?
+
+ {linkedActivity && ( + + )}
-
- {message.status === 'submitting' - ?
-
- ))} + ); + })} {streamingReplies.map((reply) => (
; }; type ExecuteCommandOptions = { @@ -63,6 +79,7 @@ type ImageWorkspaceState = { pendingOperations: Record; fieldDrafts: Record; chatDraft: string; + assistantActivities: Record; assistantStreams: Record; quoteBlockers: DesignCompilationIssue[]; error: string | null; @@ -98,7 +115,15 @@ let activeEventSource: EventSource | null = null; let activeEventWorkspaceId: string | null = null; let eventConnectionGeneration = 0; let selectionGeneration = 0; +let workspaceSelectionGeneration = 0; const assistantStreamChunkIndexes = new Map(); +const observedCommandTerminalEvents = new Map(); +const ASSISTANT_PROGRESS_STAGES: DesignAssistantProgressStage[] = [ + 'understanding', + 'reviewing_context', + 'validating', + 'composing', +]; function updateBackgroundLease(tasks: DesignGenerationTask[]): void { const active = tasks.some((task) => task.status === 'queued' || task.status === 'running'); @@ -161,6 +186,140 @@ function isUnknownCommandOutcome(error: unknown): boolean { return error instanceof ImageWorkspaceApiError && error.commandOutcome === 'unknown'; } +function terminalCommandError(event: DesignCommandTerminalEvent): ImageWorkspaceApiError { + const cancelled = event.outcome === 'cancelled'; + return new ImageWorkspaceApiError( + 422, + event.errorCode ?? (cancelled ? 'design_agent_run_cancelled' : 'design_agent_run_failed'), + cancelled + ? '这次整理已停止,内容还在输入框里' + : 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + 'definitive_failure', + ); +} + +function isChatCommand(command: DesignCommandInput): boolean { + return command.kind === 'apply_input' && command.input.kind === 'chat'; +} + +function matchingChatTurn( + workspace: DesignWorkspace, + operation: PendingDesignOperation, + pendingOperations: Record, + activity: DesignAssistantActivity | undefined, +): DesignWorkspace['turns'][number] | null { + if (activity?.turnId) { + return workspace.turns.find((turn) => turn.turnId === activity.turnId) ?? null; + } + const baseRawTurnSequence = operation.baseRawTurnSequence; + const command = operation.command; + if (baseRawTurnSequence === null + || command.kind !== 'apply_input' + || command.input.kind !== 'chat') return null; + const message = command.input.message; + const competingOperation = Object.values(pendingOperations).some((candidate) => ( + candidate.id !== operation.id + && candidate.baseRawTurnSequence === baseRawTurnSequence + && candidate.command.kind === 'apply_input' + && candidate.command.input.kind === 'chat' + && candidate.command.input.message === message + )); + if (competingOperation) return null; + const matches = workspace.turns + .filter((turn) => ( + turn.rawTurnSequence > baseRawTurnSequence + && turn.userMessage === message + )) + .sort((left, right) => left.rawTurnSequence - right.rawTurnSequence); + return matches.length === 1 ? matches[0] : null; +} + +function isCurrentOperationSelection( + state: Pick, + operation: PendingDesignOperation, +): boolean { + return state.activeWorkspaceId === operation.command.workspaceId + && (operation.workspaceSelectionGeneration ?? workspaceSelectionGeneration) + === workspaceSelectionGeneration; +} + +function withoutAssistantActivity( + activities: Record, + operationId: string, +): Record { + if (!activities[operationId]) return activities; + const next = { ...activities }; + delete next[operationId]; + return next; +} + +function withAssistantActivityStatus( + activities: Record, + operationId: string, + status: DesignAssistantActivity['status'], + turnId?: string | null, +): Record { + const activity = activities[operationId]; + if (!activity) return activities; + return { + ...activities, + [operationId]: { + ...activity, + status, + ...(turnId === undefined ? {} : { turnId }), + }, + }; +} + +function withAssistantProgress( + activities: Record, + event: DesignAssistantProgressEvent, +): Record { + const activity = activities[event.clientOperationId]; + if (activity?.status === 'completed') return activities; + const incomingRank = ASSISTANT_PROGRESS_STAGES.indexOf(event.stage); + const lastStage = activity?.steps.at(-1)?.stage; + const lastRank = lastStage ? ASSISTANT_PROGRESS_STAGES.indexOf(lastStage) : -1; + if (incomingRank < lastRank) return activities; + + const steps = [...(activity?.steps ?? [])]; + const existingIndex = steps.findIndex((step) => step.stage === event.stage); + const nextStep = { stage: event.stage, message: event.message }; + if (existingIndex >= 0) { + steps[existingIndex] = nextStep; + steps.splice(existingIndex + 1); + } else { + steps.push(nextStep); + } + return { + ...activities, + [event.clientOperationId]: { + workspaceId: event.workspaceId, + directionId: event.directionId, + status: 'active', + turnId: activity?.turnId ?? null, + steps, + }, + }; +} + +function withCompletedAssistantActivity( + activities: Record, + pendingOperations: Record, + operation: PendingDesignOperation, + workspace: DesignWorkspace, +): Record { + const turn = matchingChatTurn( + workspace, + operation, + pendingOperations, + activities[operation.id], + ); + return turn + ? withAssistantActivityStatus(activities, operation.id, 'completed', turn.turnId) + : withAssistantActivityStatus(activities, operation.id, 'completed'); +} + function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null { const data = (event as MessageEvent).data; if (typeof data !== 'string') return null; @@ -169,7 +328,11 @@ function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null { if (typeof parsed.id !== 'string' || typeof parsed.type !== 'string') return null; if (![ 'design.session.snapshot', + 'design.assistant.progress', 'design.assistant.delta', + 'command.completed', + 'command.failed', + 'command.cancelled', 'design.direction.updated', 'design.quote.blocked', 'design.workspace.updated', @@ -275,10 +438,98 @@ export const useImageWorkspaceStore = create((set, get) => return workspace; }; + const settleSuccessfulCommand = ( + operation: PendingDesignOperation, + workspace: DesignWorkspace, + ): boolean => { + let settled = false; + set((state) => { + const currentOperation = state.pendingOperations[operation.id]; + if (!currentOperation) return {}; + const pendingOperations = { ...state.pendingOperations }; + const assistantStreams = { ...state.assistantStreams }; + + if (!isCurrentOperationSelection(state, currentOperation)) { + delete pendingOperations[operation.id]; + delete assistantStreams[operation.id]; + settled = true; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + }; + } + + const canonicalWorkspace = state.workspace?.workspace.workspaceId + === workspace.workspace.workspaceId + && state.workspace.workspace.workspaceViewRevision + > workspace.workspace.workspaceViewRevision + ? state.workspace + : workspace; + const chatMessage = currentOperation.command.kind === 'apply_input' + && currentOperation.command.input.kind === 'chat' + ? currentOperation.command.input.message + : null; + const canonicalTurn = chatMessage !== null + ? matchingChatTurn( + canonicalWorkspace, + currentOperation, + state.pendingOperations, + state.assistantActivities[operation.id], + ) + : null; + if (chatMessage !== null && !canonicalTurn) { + return { + ...withWorkspace(state, canonicalWorkspace), + pendingOperations: { + ...state.pendingOperations, + [operation.id]: { ...currentOperation, status: 'unknown', error: null }, + }, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'completed', + ), + error: null, + }; + } + + delete pendingOperations[operation.id]; + delete assistantStreams[operation.id]; + const fieldDrafts = { ...state.fieldDrafts }; + for (const path of currentOperation.clearDraftPaths) delete fieldDrafts[path]; + const clearChatDraft = currentOperation.clearChatDraft + && (chatMessage === null || state.chatDraft.trim() === chatMessage); + settled = true; + return { + ...withWorkspace(state, canonicalWorkspace), + pendingOperations, + fieldDrafts, + chatDraft: clearChatDraft ? '' : state.chatDraft, + assistantActivities: chatMessage !== null + ? withCompletedAssistantActivity( + state.assistantActivities, + state.pendingOperations, + currentOperation, + canonicalWorkspace, + ) + : withoutAssistantActivity(state.assistantActivities, operation.id), + assistantStreams, + }; + }); + if (settled) assistantStreamChunkIndexes.delete(operation.id); + return settled; + }; + const executeCommand = async ( command: DesignCommandInput, options: ExecuteCommandOptions, ): Promise => { + const existingOperation = get().pendingOperations[command.clientOperationId]; + const currentWorkspace = get().workspace; const operation: PendingDesignOperation = { id: command.clientOperationId, label: options.label, @@ -287,60 +538,137 @@ export const useImageWorkspaceStore = create((set, get) => error: null, clearDraftPaths: options.clearDraftPaths ?? [], clearChatDraft: options.clearChatDraft ?? false, + baseRawTurnSequence: existingOperation?.baseRawTurnSequence + ?? (isChatCommand(command) + && currentWorkspace?.workspace.workspaceId === command.workspaceId + ? currentWorkspace.form.rawTurnSequence + : null), + workspaceSelectionGeneration, }; - set((state) => ({ - pendingOperations: { ...state.pendingOperations, [operation.id]: operation }, - error: null, - quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers, - })); + set((state) => { + const existingActivity = state.assistantActivities[operation.id]; + const assistantActivities = isChatCommand(command) + ? existingActivity + ? { + [operation.id]: { + ...existingActivity, + status: 'active' as const, + }, + } + : {} + : state.assistantActivities; + return { + pendingOperations: { ...state.pendingOperations, [operation.id]: operation }, + assistantActivities, + error: null, + quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers, + }; + }); try { const result = await submitImageWorkspaceCommand(command); - assistantStreamChunkIndexes.delete(operation.id); - set((state) => { - const pendingOperations = { ...state.pendingOperations }; - delete pendingOperations[operation.id]; - const fieldDrafts = { ...state.fieldDrafts }; - for (const path of operation.clearDraftPaths) delete fieldDrafts[path]; - const assistantStreams = { ...state.assistantStreams }; - delete assistantStreams[operation.id]; - return { - ...withWorkspace(state, result.workspace), - pendingOperations, - fieldDrafts, - chatDraft: operation.clearChatDraft ? '' : state.chatDraft, - assistantStreams, - }; - }); + const observedTerminal = observedCommandTerminalEvents.get(operation.id); + if (observedTerminal && observedTerminal.outcome !== 'succeeded') { + throw terminalCommandError(observedTerminal); + } + observedCommandTerminalEvents.delete(operation.id); + settleSuccessfulCommand(operation, result.workspace); return result.workspace; } catch (error) { + const observedTerminal = observedCommandTerminalEvents.get(operation.id); + if (observedTerminal) { + if (observedTerminal.outcome === 'succeeded') { + const refreshed = await fetchImageWorkspaceProject(command.workspaceId).catch(() => null); + if (refreshed) { + observedCommandTerminalEvents.delete(operation.id); + settleSuccessfulCommand(operation, refreshed); + return refreshed; + } + throw error; + } + + observedCommandTerminalEvents.delete(operation.id); + assistantStreamChunkIndexes.delete(operation.id); + const terminalError = terminalCommandError(observedTerminal); + set((state) => { + const pendingOperations = { ...state.pendingOperations }; + delete pendingOperations[operation.id]; + const assistantStreams = { ...state.assistantStreams }; + delete assistantStreams[operation.id]; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) + ? terminalError.message + : state.error, + }; + }); + throw terminalError; + } + const message = messageForError(error); if (isUnknownCommandOutcome(error)) { - set((state) => ({ - pendingOperations: { - ...state.pendingOperations, - [operation.id]: { ...operation, status: 'unknown', error: message }, - }, - ...(isAuthError(error) ? { status: 'auth-required' as const } : {}), - error: '操作结果尚未确认,可使用同一操作标识安全重试', - })); + set((state) => { + const active = isCurrentOperationSelection(state, operation); + return { + pendingOperations: { + ...state.pendingOperations, + [operation.id]: { ...operation, status: 'unknown', error: message }, + }, + ...(isAuthError(error) ? { status: 'auth-required' as const } : {}), + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'unknown', + ), + error: active + ? '操作结果尚未确认,可使用同一操作标识安全重试' + : state.error, + }; + }); } else if (isDirectionConflict(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, error: '设计已在其他位置更新,已刷新最新内容,请再次提交' }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + error: isCurrentOperationSelection(state, operation) + ? '设计已在其他位置更新,已刷新最新内容,请再次提交' + : state.error, + }; }); - await get().refreshWorkspace().catch(() => null); + if (isCurrentOperationSelection(get(), operation)) { + await get().refreshWorkspace().catch(() => null); + } } else if (isAuthError(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, status: 'auth-required', error: message }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + status: 'auth-required', + error: message, + }; }); } else if (isQuoteBlocked(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, error: null }; + return { + pendingOperations, + error: isCurrentOperationSelection(state, operation) ? null : state.error, + }; }); } else if (isDefinitiveCommandFailure(error)) { assistantStreamChunkIndexes.delete(operation.id); @@ -349,16 +677,33 @@ export const useImageWorkspaceStore = create((set, get) => delete pendingOperations[operation.id]; const assistantStreams = { ...state.assistantStreams }; delete assistantStreams[operation.id]; - return { pendingOperations, assistantStreams, error: message }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) ? message : state.error, + }; }); - if (error.status === 409) await get().refreshWorkspace().catch(() => null); + if (error.status === 409 && isCurrentOperationSelection(get(), operation)) { + await get().refreshWorkspace().catch(() => null); + } } else { set((state) => ({ pendingOperations: { ...state.pendingOperations, [operation.id]: { ...operation, status: 'unknown', error: message }, }, - error: '操作结果尚未确认,可使用同一操作标识安全重试', + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'unknown', + ), + error: isCurrentOperationSelection(state, operation) + ? '操作结果尚未确认,可使用同一操作标识安全重试' + : state.error, })); } throw error; @@ -376,6 +721,7 @@ export const useImageWorkspaceStore = create((set, get) => pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -396,6 +742,7 @@ export const useImageWorkspaceStore = create((set, get) => ) ? currentId : bootstrap.workspaces[0]?.workspaceId ?? null; + if (selectedId !== currentId) workspaceSelectionGeneration += 1; set({ status: 'ready', bootstrap, @@ -430,12 +777,14 @@ export const useImageWorkspaceStore = create((set, get) => createProject: async (title) => { const workspace = await createImageWorkspaceProject(title); selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); set((state) => ({ ...withWorkspace(state, workspace), fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], lastEventId: null, @@ -473,6 +822,7 @@ export const useImageWorkspaceStore = create((set, get) => const deletingActive = get().activeWorkspaceId === workspaceId; if (deletingActive) { selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); } @@ -483,6 +833,7 @@ export const useImageWorkspaceStore = create((set, get) => deletingWorkspaceId: null, fieldDrafts: deletingActive ? {} : state.fieldDrafts, chatDraft: deletingActive ? '' : state.chatDraft, + assistantActivities: deletingActive ? {} : state.assistantActivities, assistantStreams: deletingActive ? {} : state.assistantStreams, error: null, })); @@ -500,6 +851,8 @@ export const useImageWorkspaceStore = create((set, get) => selectProject: async (workspaceId) => { if (workspaceId === get().activeWorkspaceId && get().workspace) return; selectionGeneration += 1; + workspaceSelectionGeneration += 1; + const selectedWorkspaceGeneration = workspaceSelectionGeneration; closeEventSource(); assistantStreamChunkIndexes.clear(); set({ @@ -509,6 +862,7 @@ export const useImageWorkspaceStore = create((set, get) => lastEventId: null, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -517,7 +871,10 @@ export const useImageWorkspaceStore = create((set, get) => await refreshWorkspaceById(workspaceId); get().connectEvents(); } catch (error) { - set({ error: messageForError(error), eventState: 'degraded' }); + if (get().activeWorkspaceId === workspaceId + && workspaceSelectionGeneration === selectedWorkspaceGeneration) { + set({ error: messageForError(error), eventState: 'degraded' }); + } } }, @@ -548,20 +905,128 @@ export const useImageWorkspaceStore = create((set, get) => return; } activeEventSource = source; - source.onopen = () => set({ eventState: 'connected' }); + source.onopen = () => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; + set({ eventState: 'connected' }); + }; source.onerror = () => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; set({ eventState: 'degraded' }); void get().refreshWorkspace().catch(() => null); }; const receive = (raw: Event) => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; const event = parseWorkspaceEvent(raw); if (!event || get().activeWorkspaceId !== workspaceId) return; + if (event.type === 'command.completed' + || event.type === 'command.failed' + || event.type === 'command.cancelled') { + const pending = get().pendingOperations[event.clientOperationId]; + if (!pending || pending.command.workspaceId !== workspaceId) { + set({ lastEventId: event.id }); + return; + } + observedCommandTerminalEvents.set(event.clientOperationId, event); + if (event.outcome === 'succeeded') { + set((state) => ({ + lastEventId: event.id, + pendingOperations: { + ...state.pendingOperations, + [event.clientOperationId]: { + ...pending, + status: 'unknown', + error: null, + }, + }, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + ), + })); + void get().refreshWorkspace().then((refreshed) => { + if (refreshed) settleSuccessfulCommand(pending, refreshed); + }).catch(() => undefined).finally(() => { + if (pending.status === 'unknown') { + observedCommandTerminalEvents.delete(event.clientOperationId); + } + }); + return; + } + assistantStreamChunkIndexes.delete(event.clientOperationId); + set((state) => { + const operation = state.pendingOperations[event.clientOperationId]; + if (!operation || operation.command.workspaceId !== workspaceId) { + return { lastEventId: event.id }; + } + const pendingOperations = { ...state.pendingOperations }; + delete pendingOperations[event.clientOperationId]; + const assistantStreams = { ...state.assistantStreams }; + delete assistantStreams[event.clientOperationId]; + return { + lastEventId: event.id, + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + event.clientOperationId, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) + ? terminalCommandError(event).message + : state.error, + }; + }); + if (pending.status === 'unknown') { + observedCommandTerminalEvents.delete(event.clientOperationId); + } + return; + } + if (event.type === 'design.assistant.progress') { + set((state) => { + const pending = state.pendingOperations[event.clientOperationId]; + if (!state.workspace + || pending?.command.kind !== 'apply_input' + || pending.command.input.kind !== 'chat' + || pending.command.workspaceId !== workspaceId + || event.workspaceId !== workspaceId + || event.directionId !== state.workspace.form.directionId) { + return { lastEventId: event.id }; + } + return { + lastEventId: event.id, + assistantActivities: withAssistantProgress(state.assistantActivities, event), + }; + }); + return; + } if (event.type === 'design.assistant.delta') { + const state = get(); + const pending = state.pendingOperations[event.clientOperationId]; + if (!state.workspace + || pending?.command.kind !== 'apply_input' + || pending.command.input.kind !== 'chat' + || pending.command.workspaceId !== workspaceId + || event.workspaceId !== workspaceId + || event.directionId !== state.workspace.form.directionId) { + set({ lastEventId: event.id }); + return; + } const previousChunkIndex = assistantStreamChunkIndexes.get(event.clientOperationId); - if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) return; + if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) { + set({ lastEventId: event.id }); + return; + } assistantStreamChunkIndexes.set(event.clientOperationId, event.chunkIndex); set((state) => ({ lastEventId: event.id, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + ), assistantStreams: { ...state.assistantStreams, [event.clientOperationId]: `${state.assistantStreams[event.clientOperationId] ?? ''}${event.delta}`, @@ -569,6 +1034,19 @@ export const useImageWorkspaceStore = create((set, get) => })); return; } + if (event.type === 'design.direction.updated') { + const pending = get().pendingOperations[event.clientOperationId]; + if (pending?.status === 'submitting') { + observedCommandTerminalEvents.set(event.clientOperationId, { + id: event.id, + type: 'command.completed', + workspaceId: event.form.workspaceId, + clientOperationId: event.clientOperationId, + outcome: 'succeeded', + errorCode: null, + }); + } + } set((state) => { if (!state.workspace) return { lastEventId: event.id }; const nextWorkspace = applyEventToWorkspace(state.workspace, event); @@ -578,10 +1056,20 @@ export const useImageWorkspaceStore = create((set, get) => if (event.type === 'design.quote.blocked') { delete pendingOperations[event.clientOperationId]; } + const assistantActivities = event.type === 'design.direction.updated' + && event.operation.turnId + ? withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + event.operation.turnId, + ) + : state.assistantActivities; updateBackgroundLease(nextWorkspace.tasks); return { workspace: nextWorkspace, pendingOperations, + assistantActivities, bootstrap: state.bootstrap ? { ...state.bootstrap, @@ -597,13 +1085,22 @@ export const useImageWorkspaceStore = create((set, get) => error: event.type === 'design.quote.blocked' ? null : state.error, }; }); - if (event.type === 'design.workspace.updated' && event.changedDirection) { + if (event.type === 'design.direction.updated') { + const pending = get().pendingOperations[event.clientOperationId]; + void get().refreshWorkspace().then((refreshed) => { + if (pending && refreshed) settleSuccessfulCommand(pending, refreshed); + }).catch(() => null); + } else if (event.type === 'design.workspace.updated' && event.changedDirection) { void get().refreshWorkspace().catch(() => null); } }; for (const eventType of [ 'design.session.snapshot', + 'design.assistant.progress', 'design.assistant.delta', + 'command.completed', + 'command.failed', + 'command.cancelled', 'design.direction.updated', 'design.quote.blocked', 'design.workspace.updated', @@ -726,8 +1223,10 @@ export const useImageWorkspaceStore = create((set, get) => reset: () => { selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); + observedCommandTerminalEvents.clear(); updateBackgroundLease([]); set({ status: 'idle', @@ -740,6 +1239,7 @@ export const useImageWorkspaceStore = create((set, get) => pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index dc3bdae..db9893f 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -52,6 +52,7 @@ function prepareWorkspace(overrides = {}) { pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -93,6 +94,37 @@ describe('youth AI Design Canvas page', () => { expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument(); }); + it('does not disable the current composer for another Workspace command', () => { + prepareWorkspace(); + useImageWorkspaceStore.setState({ + chatDraft: '当前项目的新想法', + pendingOperations: { + 'operation-workspace-2': { + id: 'operation-workspace-2', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-2', + sessionId: 'session-2', + expectedDirectionRevision: 2, + clientOperationId: 'operation-workspace-2', + input: { kind: 'chat', message: '另一个项目还在处理' }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + }); + + render(); + + expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '发送消息' })).toBeEnabled(); + }); + it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => { const { workspace } = prepareWorkspace(); const firstChunk = '收到,我们要制作'; @@ -115,6 +147,7 @@ describe('youth AI Design Canvas page', () => { error: null, clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, }); @@ -165,6 +198,95 @@ describe('youth AI Design Canvas page', () => { .toBeInTheDocument(); }); + it('shows public AI activity under the submitted message and collapses it beside the final turn', async () => { + const message = '画一只在月球踢球的熊猫'; + const { workspace } = prepareWorkspace({ turns: [] }); + useImageWorkspaceStore.setState({ + 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, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'active', + turnId: null, + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }, + }, + }); + + render(); + + const conversation = screen.getByTestId('image-workspace-conversation'); + const pending = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); + const activity = within(conversation).getByTestId('design-assistant-activity-operation-chat-1'); + expect(pending.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(activity).getByRole('button', { name: /AI 处理过程/ })) + .toHaveAttribute('aria-expanded', 'true'); + expect(within(activity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); + expect(within(activity).getAllByText('正在把它和前面的设计想法放在一起看…')) + .not.toHaveLength(0); + expect(within(activity).queryByText(/思考过程|推理内容/)).not.toBeInTheDocument(); + + act(() => { + useImageWorkspaceStore.setState({ + workspace: { + ...workspace, + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '这个画面很有趣。你想让它更像漫画还是电影?', + }], + }, + pendingOperations: {}, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'completed', + turnId: 'turn-2', + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }, + }, + }); + }); + + const completedActivity = within(conversation) + .getByTestId('design-assistant-activity-operation-chat-1'); + await waitFor(() => { + expect(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })) + .toHaveAttribute('aria-expanded', 'false'); + }); + expect(within(completedActivity).queryByText('正在听懂你刚补充的内容…')) + .not.toBeInTheDocument(); + fireEvent.click(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })); + expect(within(completedActivity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); + }); + it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => { const { actions } = prepareWorkspace(); const quotedWorkspace = designWorkspaceFixture({ @@ -208,6 +330,7 @@ describe('youth AI Design Canvas page', () => { error: null, clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, }); @@ -390,6 +513,7 @@ describe('youth AI Design Canvas page', () => { error: '网络已断开', clearDraftPaths: [], clearChatDraft: false, + baseRawTurnSequence: null, }, }, }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index ed5c21b..8e630bb 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -291,6 +291,15 @@ describe('AI design V2 Main route boundary', () => { async *[Symbol.asyncIterator]() { yield { id: 'session-1:9', + type: 'design.assistant.progress' as const, + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context' as const, + message: '正在把它和前面的设计想法放在一起看…', + }; + yield { + id: 'session-1:10', type: 'design.assistant.delta' as const, workspaceId: 'workspace-1', directionId: 'direction-1', @@ -316,7 +325,9 @@ describe('AI design V2 Main route boundary', () => { sessionId: 'session-1', afterEventId: 'session-1:8', }); - expect(Buffer.concat(response.chunks).toString('utf8')).toContain('event: design.assistant.delta'); + const body = Buffer.concat(response.chunks).toString('utf8'); + expect(body).toContain('event: design.assistant.progress'); + expect(body).toContain('event: design.assistant.delta'); expect(close).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 18aea60..698b8af 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -76,10 +76,12 @@ class FakeEventSource { function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, reject, resolve }; } async function loadedStore(eventSource = new FakeEventSource()) { @@ -146,6 +148,60 @@ describe('V2 Living Form store', () => { expect(secondSource.close).not.toHaveBeenCalled(); }); + it('ignores late activity from a closed connection after reconnecting to the same Workspace', async () => { + const firstSource = await loadedStore(); + const oldOnOpen = firstSource.onopen; + const oldOnError = firstSource.onerror; + const secondSource = new FakeEventSource(); + useImageWorkspaceStore.setState({ + 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, + baseRawTurnSequence: 1, + }, + }, + }); + openEventsMock.mockResolvedValueOnce(secondSource as unknown as EventSource); + + useImageWorkspaceStore.getState().disconnectEvents(); + useImageWorkspaceStore.getState().connectEvents(); + await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2)); + secondSource.onopen?.(new Event('open')); + const fetchCount = fetchProjectMock.mock.calls.length; + const event = { + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-chat-1', + stage: 'understanding', + message: '正在听懂你刚补充的内容…', + } as const; + + firstSource.emit('design.assistant.progress', { ...event, id: 'session-1:7-old' }); + oldOnOpen?.(new Event('open')); + oldOnError?.(new Event('error')); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().eventState).toBe('connected'); + expect(fetchProjectMock).toHaveBeenCalledTimes(fetchCount); + + secondSource.emit('design.assistant.progress', { ...event, id: 'session-1:7' }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-chat-1']) + .toMatchObject({ status: 'active', steps: [{ stage: 'understanding' }] }); + }); + it('allows a new event connection after opening the previous one failed', async () => { const workspace = designWorkspaceFixture(); const recoveredSource = new FakeEventSource(); @@ -168,6 +224,27 @@ describe('V2 Living Form store', () => { it('uses assistant chunk indexes to ignore replayed deltas', async () => { const source = await loadedStore(); + useImageWorkspaceStore.setState({ + 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, + baseRawTurnSequence: 1, + }, + }, + }); const baseEvent = { type: 'design.assistant.delta', workspaceId: 'workspace-1', @@ -193,10 +270,17 @@ describe('V2 Living Form store', () => { chunkIndex: 1, delta: ',正在整理', }); + source.emit('design.assistant.delta', { + ...baseEvent, + id: 'session-1:9', + chunkIndex: 1, + delta: ',正在整理', + }); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-chat-1': '收到,正在整理', }); + expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:9'); }); it('keeps a field draft separate until the direct edit succeeds', async () => { @@ -267,10 +351,762 @@ describe('V2 Living Form store', () => { }); }); + it('keeps public AI activity with its chat operation and settles it into the canonical turn', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + const progress = { + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + } as const; + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:7', + stage: 'understanding', + message: '正在听懂你刚补充的内容…', + }); + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:8', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); + + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'active', + turnId: null, + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }); + + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:9', + stage: 'understanding', + message: '迟到的旧阶段', + }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.steps) + .toHaveLength(2); + + source.emit('design.assistant.delta', { + id: 'session-1:10', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + directionRevision: 5, + chunkIndex: 0, + delta: '这个画面很有趣。', + }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.status) + .toBe('completed'); + + const workspace = designWorkspaceFixture({ + turns: [ + { + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '这个画面很有趣。', + }, + { + turnId: 'turn-3', + rawTurnSequence: 3, + userMessage: '后来补充的另一条消息', + assistantMessage: '这是后来的回复。', + }, + ], + }); + submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace }); + await sendPromise; + + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ status: 'completed', turnId: 'turn-2' }); + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + + it('settles an unknown chat from a replayed Gateway terminal event', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + useImageWorkspaceStore.setState({ + chatDraft: message, + 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, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }], + }, + }, + }); + + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'succeeded', + errorCode: null, + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + workspace: { form: { rawTurnSequence: 2 } }, + assistantActivities: { + 'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' }, + }, + }); + }); + + it('keeps a succeeded terminal event authoritative when command polling then disconnects', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValue(canonical); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'succeeded', + errorCode: null, + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).resolves.toMatchObject({ + form: { rawTurnSequence: 2 }, + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + error: null, + pendingOperations: {}, + workspace: { form: { rawTurnSequence: 2 } }, + }); + }); + + it('keeps a failed terminal event authoritative when command polling then disconnects', async () => { + const source = await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('画一只机器人'); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).rejects.toMatchObject({ + code: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '画一只机器人', + error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + pendingOperations: {}, + assistantActivities: {}, + assistantStreams: {}, + }); + }); + + it('does not let a late polling success overwrite a Gateway terminal failure', async () => { + const source = await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('画一只机器人'); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-1', + workspace: designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }), + }); + + await expect(sendPromise).rejects.toMatchObject({ + code: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '画一只机器人', + error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + pendingOperations: {}, + }); + }); + + it('does not resurrect pending after direction success settles before polling disconnects', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValue(canonical); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + + source.emit('design.direction.updated', { + id: 'session-1:19', + type: 'design.direction.updated', + clientOperationId: 'operation-1', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-2', + baseDirectionRevision: 4, + newDirectionRevision: 5, + rawTurnSequence: 2, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 7, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-2', changes: [] }, + turnId: 'turn-2', + assistantMessage: '你希望它更像漫画还是电影?', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }); + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'succeeded', + errorCode: null, + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).resolves.toMatchObject({ form: { rawTurnSequence: 2 } }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + error: null, + pendingOperations: {}, + workspace: { form: { rawTurnSequence: 2 } }, + }); + }); + + it('keeps the optimistic chat until a terminal success is visible in the canonical Workspace', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + useImageWorkspaceStore.setState({ + chatDraft: message, + 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, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + }, + }); + fetchProjectMock.mockResolvedValueOnce(designWorkspaceFixture()); + + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'succeeded', + errorCode: null, + }); + await vi.waitFor(() => { + expect(fetchProjectMock).toHaveBeenCalledTimes(2); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: message, + pendingOperations: { + 'operation-chat-unknown': { status: 'unknown' }, + }, + }); + + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + source.emit('design.direction.updated', { + id: 'session-1:21', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-unknown', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-2', + baseDirectionRevision: 4, + newDirectionRevision: 5, + rawTurnSequence: 2, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 7, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-2', changes: [] }, + turnId: 'turn-2', + assistantMessage: '你希望它更像漫画还是电影?', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + assistantActivities: { + 'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' }, + }, + }); + }); + + it('uses the Gateway operation turn ID when identical chats are both pending', async () => { + const source = await loadedStore(); + const message = '再画一只机器人'; + const command = (operationId: string) => ({ + kind: 'apply_input' as const, + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: operationId, + input: { kind: 'chat' as const, message }, + }); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-1': { + id: 'operation-chat-1', + label: '发送创作想法', + command: command('operation-chat-1'), + status: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + 'operation-chat-2': { + id: 'operation-chat-2', + label: '发送创作想法', + command: command('operation-chat-2'), + status: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + 'operation-chat-2': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + }, + }); + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }), + turns: [ + { + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '第一条相同消息的回复。', + }, + { + turnId: 'turn-3', + rawTurnSequence: 3, + userMessage: message, + assistantMessage: '第二条相同消息的回复。', + }, + ], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + + source.emit('design.direction.updated', { + id: 'session-1:22', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-2', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-3', + baseDirectionRevision: 5, + newDirectionRevision: 6, + rawTurnSequence: 3, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 8, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-3', changes: [] }, + turnId: 'turn-3', + assistantMessage: '第二条相同消息的回复。', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }), + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-2']) + .toBeUndefined(); + }); + expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-1']) + .toBeDefined(); + expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({ + 'operation-chat-1': { turnId: null }, + 'operation-chat-2': { status: 'completed', turnId: 'turn-3' }, + }); + }); + + it('settles an unknown chat when Gateway later reports terminal failure', async () => { + const source = await loadedStore(); + useImageWorkspaceStore.setState({ + 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, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }], + }, + }, + }); + + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().error) + .toBe('AI 这次没有完成设计整理,内容还在输入框里,请再试一次'); + }); + + it('does not replace the active Workspace when an earlier Workspace command completes late', async () => { + await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('旧项目里的消息'); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + const workspaceB = designWorkspaceFixture({ + workspace: designSummaryFixture({ + workspaceId: 'workspace-2', + clientWorkspaceId: 'client-workspace-2', + directionId: 'direction-2', + sessionId: 'session-2', + title: '当前项目', + }), + form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }), + }); + useImageWorkspaceStore.setState({ + activeWorkspaceId: 'workspace-2', + workspace: workspaceB, + assistantActivities: {}, + assistantStreams: {}, + }); + + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-old', + workspace: designWorkspaceFixture(), + }); + await sendPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-2', + workspace: { workspace: { workspaceId: 'workspace-2', title: '当前项目' } }, + pendingOperations: {}, + }); + }); + + it('does not apply an old A result after the user switches A to B and back to A', async () => { + await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('旧的 A 项目消息'); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + + const workspaceB = designWorkspaceFixture({ + workspace: designSummaryFixture({ + workspaceId: 'workspace-2', + clientWorkspaceId: 'client-workspace-2', + directionId: 'direction-2', + sessionId: 'session-2', + title: '项目 B', + }), + form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }), + }); + const currentWorkspaceA = designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }), + form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }), + turns: [{ + turnId: 'turn-4', + rawTurnSequence: 4, + userMessage: 'A 项目中较新的消息', + assistantMessage: '这是较新的回复。', + }], + }); + fetchProjectMock + .mockResolvedValueOnce(workspaceB) + .mockResolvedValueOnce(currentWorkspaceA); + await useImageWorkspaceStore.getState().selectProject('workspace-2'); + await useImageWorkspaceStore.getState().selectProject('workspace-1'); + + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-old-a', + workspace: designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, workspaceViewRevision: 7 }), + }), + }); + await sendPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-1', + pendingOperations: {}, + workspace: { + workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 }, + form: { rawTurnSequence: 4, workspaceViewRevision: 12 }, + }, + }); + }); + + it('does not show a late B loading error after the user returns to A', async () => { + await loadedStore(); + const workspaceB = deferred>>(); + const currentWorkspaceA = designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }), + form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }), + }); + fetchProjectMock + .mockReturnValueOnce(workspaceB.promise) + .mockResolvedValueOnce(currentWorkspaceA); + + const selectBPromise = useImageWorkspaceStore.getState().selectProject('workspace-2'); + await useImageWorkspaceStore.getState().selectProject('workspace-1'); + workspaceB.reject(new Error('项目 B 加载失败')); + await selectBPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-1', + error: null, + workspace: { + workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 }, + form: { rawTurnSequence: 4, workspaceViewRevision: 12 }, + }, + }); + }); + + it('ignores progress and reply deltas that do not belong to the active pending chat', async () => { + const source = await loadedStore(); + + source.emit('design.assistant.progress', { + id: 'session-1:7', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'unrelated-operation', + stage: 'understanding', + message: '不应显示', + }); + source.emit('design.assistant.delta', { + id: 'session-1:8', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'unrelated-operation', + directionRevision: 4, + chunkIndex: 0, + delta: '不应显示', + }); + + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); + expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:8'); + }); + it('settles a definitive reasoner failure without losing the chat draft', async () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.progress', { + id: 'session-1:6', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', @@ -299,6 +1135,7 @@ describe('V2 Living Form store', () => { error: 'AI 没有整理好这次想法,请再试一次', }); expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); }); @@ -324,6 +1161,15 @@ describe('V2 Living Form store', () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.progress', { + id: 'session-1:6', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', @@ -356,6 +1202,8 @@ describe('V2 Living Form store', () => { expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-1': '正在整理', }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ status: 'unknown', steps: [{ stage: 'reviewing_context' }] }); }); it('expires quote blockers only after the canonical Specification changes', async () => { @@ -540,6 +1388,7 @@ describe('V2 Living Form store', () => { error: '结果尚未确认', clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, quoteBlockers: [{ @@ -562,6 +1411,7 @@ describe('V2 Living Form store', () => { source.emit('design.direction.updated', { id: 'session-1:8', type: 'design.direction.updated', + clientOperationId: 'operation-chat-1', replayed: false, operation: { operationKind: 'chat', @@ -594,7 +1444,6 @@ describe('V2 Living Form store', () => { expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-chat-unknown': '这段回复还没确认', - 'operation-chat-1': '正在整理', }); expect(useImageWorkspaceStore.getState()).toMatchObject({ lastEventId: 'session-1:8', diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index cd1eea6..02ff012 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -390,6 +390,198 @@ describe('Works Square V2 Design Workspace adapter', () => { subscription.close(); }); + it('normalizes bounded public AI activity without exposing provider details', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const nextEvent = subscription.events[Symbol.asyncIterator]().next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 8, + runtime: 'design', + type: 'design.assistant.progress', + schema_version: 1, + payload: { + workspace_id: 'workspace-1', + direction_id: 'direction-1', + client_operation_id: 'operation-chat-1', + stage: 'reviewing_context', + message: 'RAW provider chain-of-thought must not cross Main', + }, + }, + }), + }); + + await expect(nextEvent).resolves.toEqual({ + done: false, + value: { + id: 'session-1:8', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-chat-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }, + }); + subscription.close(); + }); + + it('carries the Gateway operation identity with the canonical Design turn', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const nextEvent = subscription.events[Symbol.asyncIterator]().next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 9, + runtime: 'design', + type: 'design.direction.updated', + client_command_id: 'operation-chat-1', + schema_version: 1, + payload: { + replayed: false, + operation: { + operation_kind: 'chat', + interaction_id: 'interaction-2', + base_direction_revision: 4, + new_direction_revision: 5, + raw_turn_sequence: 2, + specification_revision: 3, + specification_revision_id: 'specification-revision-3', + workspace_view_revision: 7, + specification_revision_created: false, + meaning_changed: false, + change_set: { interaction_id: 'interaction-2', changes: [] }, + turn_id: 'turn-2', + assistant_message: '你希望它更像漫画还是电影?', + created_decision_prompt_ids: [], + resolved_decision_prompt_id: null, + superseded_decision_prompt_ids: [], + superseded_quote_count: 0, + quote_id: null, + generation_task_id: null, + }, + form: { ...serverForm, raw_turn_sequence: 2, direction_revision: 5 }, + }, + }, + }), + }); + + await expect(nextEvent).resolves.toMatchObject({ + done: false, + value: { + id: 'session-1:9', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-1', + operation: { turnId: 'turn-2' }, + }, + }); + subscription.close(); + }); + + it('normalizes Gateway command terminal events by their stable operation identity', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const iterator = subscription.events[Symbol.asyncIterator](); + const nextEvent = iterator.next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 9, + runtime: 'design', + type: 'command.failed', + client_command_id: 'operation-chat-1', + schema_version: 1, + terminal: true, + payload: { + error: { + code: 'design_reasoner_invalid', + message: 'private technical detail', + retryable: false, + }, + }, + }, + }), + }); + + await expect(nextEvent).resolves.toEqual({ + done: false, + value: { + id: 'session-1:9', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }, + }); + subscription.close(); + }); + it('refreshes authentication once after a 401', async () => { getTokenMock.mockResolvedValueOnce('expired-token').mockResolvedValueOnce('fresh-token'); const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => { From 7bdb42f26c60747fbabf3bb3a00e3df3477a46a3 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 7 Sep 2026 12:21:40 +0800 Subject: [PATCH 2/3] feat(design): integrate agent activity timeline --- .../adr-007-ai-design-living-form-v2.md | 11 ++ .project-docs/20-architecture/data-flow.md | 3 +- .project-docs/20-architecture/module-map.md | 10 +- .../20-architecture/system-overview.md | 7 +- .project-docs/30-worklog/current-state.md | 21 +++- ...7-design-agent-activity-client-b6d913e4.md | 103 ------------------ ...ntegrate-design-agent-activity-b7d3f1a8.md | 64 +++++++++++ .project-docs/40-domain/business-rules.md | 3 +- .../ImageCanvas/DesignConversationPane.tsx | 36 +++--- src/stores/image-workspace.ts | 3 +- tests/unit/image-canvas-page.test.tsx | 57 ++++++++++ tests/unit/image-workspace-store.test.ts | 58 ++++++++++ 12 files changed, 243 insertions(+), 133 deletions(-) delete mode 100644 .project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md create mode 100644 .project-docs/30-worklog/tasks/20260907-integrate-design-agent-activity-b7d3f1a8.md diff --git a/.project-docs/10-decisions/adr-007-ai-design-living-form-v2.md b/.project-docs/10-decisions/adr-007-ai-design-living-form-v2.md index 33ef7cd..8e7f8f4 100644 --- a/.project-docs/10-decisions/adr-007-ai-design-living-form-v2.md +++ b/.project-docs/10-decisions/adr-007-ai-design-living-form-v2.md @@ -4,6 +4,9 @@ Accepted / implemented +Amended on 2026-09-07 to define operation-scoped public activity as transient +presentation state beneath the matching optimistic user message. + ## Date 2026-08-30 @@ -27,6 +30,12 @@ versioned Design Specification and a persistent form throughout the design flow. - Every mutation carries stable command and semantic operation identities. An unknown transport result keeps the same command for replay; revision conflicts refresh canonical state without discarding local drafts. +- While a chat operation is pending, Electron Main may normalize the server's closed + `design.assistant.progress` lifecycle into fixed youth-safe stages, and Renderer + may show one transient activity panel beneath the matching optimistic user message. + Activity is not a canonical Turn, Current Specification state, assistant content, + or evidence that a change committed. Model chain-of-thought, prompts, Provider + responses, tool arguments, and unvalidated model text are never rendered. - Generation is a compile-and-confirm boundary. The server returns an immutable Quote for one exact Specification revision; the client displays the public output plan, warnings, expiry, and Token Point amount and confirms only the Quote ID. @@ -51,6 +60,8 @@ and uncertain retries auditable without exposing provider internals. old clients and V1 semantic writers receive no compatibility window. - Production database cutover, paid Provider activation, and real-account installed client smoke remain separate operator gates. +- Replayed activity and generic terminal Gateway events reconcile by the original + operation identity. They do not create another message, form, or retry intent. ## Supersedes diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index a4c2065..34a5e7f 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -30,6 +30,7 @@ | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | | Design Workspace load | Canvas/Sidebar | Renderer Host API → Main-owned Works Square V2 adapter → current Workspace/Direction/Living Form projection | 一个 Workspace 公开一个 current Direction 与 persistent Agent Session;conversation timeline 是交互历史,不是独立 authority object | | Design input and reconciliation | Chat/direct edit/decision/proposal/lock/Asset binding/restore | Renderer draft → Main → `design.input.apply` → canonical Direction projection | 全部进入同一 reducer;accepted 前 draft 保持本地,revision conflict 刷新权威投影,unknown result 复用原 command/operation identity | +| Design activity and reply streaming | Direction Gateway journal | Main 固定活动阶段归一化 → Renderer operation-scoped activity;committed assistant delta → matching provisional assistant bubble → canonical Turn | `design.assistant.progress` 只显示在对应 optimistic user message 下方,不成为消息或右侧摘要;`design.assistant.delta` 只投影已提交回复。两者都按 Workspace、Direction、operation identity 和 replay cursor 收敛,均不改写 Specification | | 设计有限请求生命周期 | Renderer Workspace JSON 调用 / shared Works token refresh | Main deadline → Electron transport → safe-read-only Node fallback → Host API timeout envelope | 包括取凭据、请求头和响应 body 的完整生命周期最多 30 秒;超时 abort transport 并稳定返回 `504 DESIGN_WORKSPACE_REQUEST_TIMEOUT`;透明 fallback 只允许 `GET`/`HEAD`/`OPTIONS`,mutation 不隐式重放 | | Design Asset binding | 当前 Workspace 已完成图片 / 本地上传 | canonical Asset → typed `asset_binding` input → Current Specification revision | 图片/视频用途由 Specification medium 和 binding role 决定;只提交真实 Workspace Asset identity,不使用本地路径或自然语言占位 | | Design Quote and confirmation | 当前 exact Specification revision | `design.quote.request` → immutable public Quote projection → `design.generation.confirm` with Quote ID | Renderer 只展示 output summary、warnings、expiry、Token Points;Provider Prompt/model/route/storage/internal billing 不下发或编辑 | @@ -81,4 +82,4 @@ ## Last Updated -2026-09-06 +2026-09-07 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index e4d0d7c..0205339 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -35,11 +35,11 @@ | `electron/coding-runtime/pi/{extension-host,subagent,write-lease}.ts` / `electron/main/background-lifecycle.ts` | 必需 Makelore extension、显式安装且启用的 Device Package extensions、交互、子 Agent、同项目写租约与后台运行租约 | parent generation 加载完整 Main-selected extension 集;child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或确认超时被回收,所有 stop/replacement 必须有原因 | | `electron/main/updater.ts` | 目标 feed 解析、electron-updater 生命周期与原始诊断 | 缺少稳定 manifest 保持错误;事件/Promise 重复失败按检查实例去重 | | `src/components/settings/UpdateSettings.tsx` | 更新状态、重试与用户可读错误展示 | 只显示一条简洁中文提示;技术诊断统一回退到本地化通用文案 | -| `shared/image-workspace.ts` | AI Design V2 Workspace、Direction、Living Form、immutable Quote、Task、Asset 与事件共享契约 | Current Specification 是唯一语义权威;Task/Asset 属于独立 Workspace resource projection | +| `shared/image-workspace.ts` | AI Design V2 Workspace、Direction、Living Form、immutable Quote、Task、Asset 与事件共享契约 | Current Specification 是唯一语义权威;Task/Asset 属于独立 Workspace resource projection;public activity 是固定、operation-scoped presentation contract | | `electron/api/routes/image-workspace.ts` | AI 绘画 Host API 与本地事件流投影 | Renderer 只通过该路由访问 Main-owned workspace adapter | -| `electron/image-workspace/works-square-workspace.ts` | Works Square V2 adapter、stable command/operation IDs 与 Direction event mapping | unknown transport result 保留精确命令;Main 持有 token、ticket、WebSocket、retry classification 与 safe errors | -| `src/stores/image-workspace.ts` | Current Workspace/Direction/Living Form、local drafts、pending operations 与 Workspace resources | revision conflict 刷新 canonical state;event order/Task progress 不成为 Specification authority | -| `src/pages/ImageCanvas/{index,DesignConversationPane,DesignFormPane,DesignProductionPanel}.tsx` | 持久双栏 Canvas、conversation、Living Form、immutable Quote confirmation、Tasks 与 Assets | chat/direct edits/decisions/locks/bindings 使用同一 V2 reducer;确认只提交 Quote ID | +| `electron/image-workspace/works-square-workspace.ts` | Works Square V2 adapter、stable command/operation IDs 与 Direction event mapping | unknown transport result 保留精确命令;Main 持有 token、ticket、WebSocket、retry classification、safe errors,并把闭集 progress stage 归一为固定 youth-safe copy | +| `src/stores/image-workspace.ts` | Current Workspace/Direction/Living Form、local drafts、pending operations、operation activity 与 Workspace resources | revision conflict 刷新 canonical state;activity/delta/terminal event 按 Workspace、Direction 和 operation identity 收敛;event order/Task progress 不成为 Specification authority | +| `src/pages/ImageCanvas/{index,DesignConversationPane,DesignFormPane,DesignProductionPanel}.tsx` | 持久双栏 Canvas、conversation、operation-scoped AI activity、Living Form、immutable Quote confirmation、Tasks 与 Assets | chat/direct edits/decisions/locks/bindings 使用同一 V2 reducer;固定活动附着于对应 user bubble 且不成为对话或右侧摘要;确认只提交 Quote ID | | `src/components/layout/ImageWorkspaceSidebar.tsx` | Workspace 选择、创建与 Prompt Museum 入口 | 不再创建或选择嵌套 Conversation | | `shared/image-prompt-museum.ts` | Prompt Museum 列表、分类、详情、署名与分页共享 DTO | 客户端不包含内容数据集,只定义服务端字段契约 | | `electron/api/routes/image-prompt-museum.ts` | Main-owned Museum 列表/详情代理与 Works 登录态 | 仅 GET 固定路径和白名单查询;Renderer 不获得 Bearer Token | @@ -93,4 +93,4 @@ ## Last Updated -2026-09-06 +2026-09-07 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 806b876..d6c39f7 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -27,8 +27,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Code-owned Official Project Plugins | Existing Account acquisition or system-included delivery → project enablement → effective parent snapshot | Data Service、Game Resource 与 Project Scaffold 都不要求 Agent assignment,项目启用后自动进入每个父 Agent;child 不继承 Plugin。三者不经过设备下载、更新、Beta 或 artifact 签名;Game Resource 的一次确认由 Main 提交一次、内部轮询并把全部终态输出自动写入冻结的原项目,恢复本地交付不得重新生成或计费;Project Scaffold 的 `.mjs` 仍只来自签名客户端固定资源。需要分配的 Marketplace 下载包保持原规则。 | | Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`;没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 与非空 Skill `scripts/` 拥有桌面用户权限,必须披露并独立确认;生命周期脚本禁用。每个 generation 包含所有显式安装且当前启用的 Skill/extension;新/idle parent 自动刷新,active parent 在 turn settled 后刷新,child 始终为空。 | | AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | 自然对话是主创作面;Living Form 仅以“AI 已理解”的紧凑辅助摘要与可选手动调整投影服务端 Current Specification,Renderer 只持有草稿和已接受投影 | -| AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态;待提交 chat 从同一 pending operation 临时投影,assistant delta 只能在匹配该 operation 的一个未完成助手气泡中临时绘制且不生成独立整理进度栏 | -| AI Design Gateway Routing | Main-owned Works Square V2 adapter 与 Direction event stream | Main 持有 Works Token、stream ticket、WebSocket、重试分类和错误脱敏;事件顺序与 Task progress 不构成 Specification 真值 | +| AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态;待提交 chat 从同一 pending operation 临时投影,固定 public activity 附着于该用户消息,assistant delta 只进入匹配 operation 的一个未完成助手气泡 | +| AI Design Gateway Routing | Main-owned Works Square V2 adapter 与 Direction event stream | Main 持有 Works Token、stream ticket、WebSocket、重试分类和错误脱敏;Main 将闭集 `design.assistant.progress` 阶段归一为固定中文,不透传模型推理或任意服务端文案;事件顺序与 Task progress 不构成 Specification 真值 | | AI Design Quote & Task Controls | 精确 Specification revision 编译出的不可变 Quote 与 Workspace Tasks | 客户端只展示 public output summary、warnings、expiry 与 Token Points,并以 Quote ID 确认;Provider Prompt/model/route/storage/billing atoms 不下发 | | AI Design Assets | Workspace 已完成作品或本地上传的真实 Asset | Asset 通过 typed binding 写入 Specification;生成结果经 Main-owned asset download 保存 | | Prompt Museum | Canvas “获取灵感”页面 → Main Host API → Works Square | 服务端驱动的审核内容、筛选和分页;Renderer 不持有 Works Token 或内置数据集,“使用此 Prompt”只回填输入框 | @@ -94,6 +94,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - Renderer 的异步与流式结果必须核对 Workspace、Direction、revision 和 operation identity;Direction snapshot 是 Specification 真值,Task/Asset 事件只更新 Workspace resource projection。 - 待提交 chat 可以从现有 pending operation 立即投影为明确标注“发送中/正在确认”的用户气泡,但只有服务端 canonical turn 能进入对话历史;确定失败必须恢复原草稿,不能另建一套消息状态或新业务意图。 - `design.assistant.delta` 是未完成的传输状态,不是 canonical assistant turn。Renderer 只能把它临时绘制为与同一 pending chat identity 绑定的单个未完成助手气泡,并在该 operation 确定收敛后由 canonical turn 替换;不得持久化为第二条消息、伪装为已完成回复或创建独立整理进度栏。unknown outcome 保留原 identity 与已有片段,其他 operation 的更新不得全局清除它;右侧 Current Specification 继续呈现 AI 当前整理出的设计理解。重叠连接与重放 chunk 必须按连接 generation 和 `chunkIndex` 收敛。 +- `design.assistant.progress` 是与同一 pending chat identity 绑定的瞬时活动投影,可在 optimistic user bubble 下更新和折叠。它只能使用 Main 归一化的固定阶段,不得成为 conversation Turn、右侧 Current Specification 的副本、通用全局整理栏或 model chain-of-thought;终态事件按原 operation identity 收敛它。 - 确认生成只提交不可变 Quote ID。Task 事务已提交但事件迟到时可刷新 Workspace projection;Task progress 不得改写 Living Form 或授权新的生成操作。 - Updater feed 选择、原始错误日志、下载和安装生命周期只属于 Electron Main。Renderer 不得把缺失稳定 manifest 投影为“已是最新版”,也不得显示原始堆栈、URL、路径或错误码;并发检查共享同一错误事件时只发送一次错误状态,后续独立重试仍可重新报告。 - 图片与视频复用同一个单图来源选择器。图片 Brief 可从当前 Workspace 的已完成作品或本地上传中选择一张参考图继续生成;视频 Brief 使用同一入口绑定首帧。两条路径都必须通过现有 Workspace Asset 上传/选择契约提交一个真实 `attachmentAssetIds`,不得用本地路径或自然语言描述代替资产身份。 @@ -107,4 +108,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 ## Last Updated -2026-09-06 +2026-09-07 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 1cd7092..10dc250 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,23 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- AI Design operation activity source + `81b524a02c2cf51304090afa8fb89d27c72c5d2b` from task + `20260907-design-agent-activity-client-b6d913e4` is integrated by task + `20260907-integrate-design-agent-activity-b7d3f1a8`. Electron Main now + normalizes the server's four closed public stages into fixed youth-safe copy; + Renderer attaches one transient, update-in-place `AI 处理过程` panel to the exact + optimistic user message and collapses completed work without creating a chat Turn + or duplicating the right-side Current Specification. Workspace, Direction, + operation, pre-command turn sequence, connection generation, cursor, and terminal + events keep replay and stale results scoped; unknown outcomes retain the original + identity. Integration review additionally preserves prior completed/unknown activity + when a later chat starts and keeps each pending user's activity and provisional + reply together in visual order. Final committed assistant text continues through + the existing provisional assistant bubble and canonical Turn replacement. Focused verification passed 69 + tests, TypeScript, changed-file ESLint, and all Vite production targets. No server, + database, billing, Quote/Task, navigation, package, deployment, publication, or paid + Provider call changed. - Coding project setup UX source `8042d2be265e40288e628fcdc755e9f29151306b` from task `20260906-project-setup-ux-90fe6cf2` is integrated onto local `main` by task `20260906-integrate-project-setup-ux-4b7d2e91`. A Coding Conversation is @@ -719,7 +736,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre AI Design Canvas 现在默认以对话和一张持续可见的“我的创作”卡服务 8-16 岁创作者;专业字段矩阵收进按需打开的“精细调整”,移动端保持对话优先并只挂载一个卡片/底部面板。Creation Card、精细调整、Quote、Task 与结果提示都只投影权威状态,已知问题按 code 转成通俗中文,未知服务端或 Provider 文本不会直接显示。一个 Workspace 仍只公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specification;conversation timeline 只记录交互历史。Chat、direct edits、decision responses、proposal acceptance、locks、Asset binding 与 restore 都通过 `design.input.apply` 进入同一服务端 reducer,Renderer drafts 在 accepted 前保持本地。Main 持有 Works Token、stream ticket、WebSocket、request deadline、stable command/operation IDs 与错误脱敏;unknown result 只能复用原 identity,结构化业务错误不得重放。Generation 由服务端对 exact Specification revision 编译 immutable Quote,客户端只展示 public output plan、warnings、expiry 与 Token Points,并以 Quote ID 调用 `design.generation.confirm`;Provider Prompt、model、route、storage 和 billing atoms 不进入 Renderer。Task/Asset events 独立收敛 Workspace resources,不改写 Living Form。Development 与 packaged builds 均使用 Works Square V2,V1 DTO、local semantic adapter、mutable Quote PATCH 与 editable provider Prompt 已移除。 -新提交的 Design chat 会立即从现有 pending operation 投影为带“发送中”或“正在确认”的临时用户气泡,服务端确认的 canonical turn 到达后再替换它;确定失败时原草稿重新出现在输入框。`design.assistant.delta` 只作为内部传输与收敛状态,不显示原始片段,也不在对话区生成独立整理进度栏;右侧 Current Specification 摘要继续承担“AI 当前听懂的内容”。重复连接和事件按 connection generation 与 `chunkIndex` 收敛。 +新提交的 Design chat 会立即从现有 pending operation 投影为带“发送中”或“正在确认”的临时用户气泡,服务端确认的 canonical turn 到达后再替换它;确定失败时原草稿重新出现在输入框。`design.assistant.progress` 经 Main 归一为固定通俗阶段,只在对应用户气泡下形成可更新、可折叠的“AI 处理过程”,不成为对话消息、右侧摘要或模型思考过程。`design.assistant.delta` 仅把已验证并提交的回复临时绘制为同一 operation 的未完成助手气泡,随后由 canonical turn 替换;不生成独立全局整理栏。右侧 Current Specification 摘要继续承担“AI 当前听懂的内容”。重复连接和事件按 connection generation、operation identity 与 `chunkIndex` 收敛。 Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URL;credential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。 @@ -872,4 +889,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Last Updated -2026-09-06 +2026-09-07 diff --git a/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md b/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md deleted file mode 100644 index d8612d1..0000000 --- a/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md +++ /dev/null @@ -1,103 +0,0 @@ -# Task: Render AI Design agent activity progress - -## Identity - -- Task ID: 20260907-design-agent-activity-client-b6d913e4 -- Mode: Feature -- Branch: codex/20260907-design-agent-activity-client-b6d913e4-design-agent-activity-client -- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260907-design-agent-activity-client-b6d913e4 -- Base commit: da29294979da4ce041edf992b590ee6ea170a44f -- Owner: codex-root -- Status: Ready for Integration - -## Scope - -- Consume operation-scoped AI Design activity events through the existing Main-owned - Works Square transport. -- Render one transient activity area attached to the optimistic user message, update - meaningful steps in place, and settle it correctly on final reply or terminal error. -- Cover reconnect/replay, transport-unknown, stale-operation, and compact responsive - presentation without duplicating the right-side Design summary. - -## Intent And Constraints - -- User messages remain immediately visible and final assistant replies remain normal - conversation content. -- Activity is presentation state, not canonical Design state or a durable chat bubble. -- Show at most a few youth-safe phase/action/outcome lines; never render raw reasoning, - tool arguments, prompts, provider output, or technical error details. -- Preserve stable operation identity across uncertain transport outcomes and ignore - progress that belongs to another Workspace/direction/operation. - -## Project Context Loaded - -- Project goal: a youth-friendly desktop creation flow with Electron Main as the only - network authority and server-owned Design Specification semantics. -- Current integrated focus: Living Form V2 and immediate optimistic user messages are - integrated; the experimental `050d78d` progress UI is not assumed present. -- Decisions: ADR-007 keeps Renderer drafts temporary and Gateway operations stable; - the activity view may observe events but cannot become another authority. -- Evidence/commitments: real paid Design production smoke remains separate and is not - claimed by this client presentation task. -- Relevant peers: the Enter-key E2E task is test-only; older generic-error tasks have - undefined scope. No semantic conflict was found. -- Likely modules: Main Workspace adapter/DTO mapping, Renderer store event reduction, - Design conversation pane, and focused unit/Electron E2E tests. -- Unknowns to resolve in source: current stream event DTO shape and the best existing - operation-to-optimistic-message association seam. -- Gate result: Passed. - -## Outcome - -- Added a dedicated `AI 处理过程` panel beneath the optimistic user message. Active - steps update in place; completed processing collapses into a compact, user-toggleable - summary while the final assistant reply continues to stream as normal conversation. -- Added Main-owned normalization for the four public progress stages. Main maps stages - to fixed youth-safe copy instead of trusting arbitrary server/provider text. -- Preserved exact operation association across optimistic, streaming, canonical, and - transport-unknown states by recording the pre-command turn sequence and matching the - earliest exact canonical user turn after that boundary. -- Added Gateway command terminal-event handling so accepted commands converge after a - polling disconnect. WebSocket-terminal-first races remain authoritative, stale - connections cannot mutate current state, duplicate chunks advance the replay cursor, - and late results from another Workspace cannot replace the active Workspace. -- Activity remains transient presentation state: it is filtered by Workspace and - direction, never becomes a chat message or right-side Design authority, and contains - no raw model reasoning, prompts, tool arguments, or technical provider output. - -## Verification - -- `pnpm exec vitest run tests/unit/image-workspace-store.test.ts - tests/unit/image-canvas-page.test.tsx - tests/unit/works-square-design-workspace.test.ts - tests/unit/image-workspace-route.test.ts` — 67 passed. -- `pnpm exec tsc --noEmit` — passed. -- Focused ESLint across all eight changed TypeScript/TSX files — passed. -- `pnpm run build:vite` — passed; only existing chunk-size, dynamic-import, and stale - Browserslist-data warnings were reported. -- `git diff --check` — passed. -- Independent client and lifecycle reviews were repeated after the terminal-race, - exact-turn-association, and Workspace A→B→A fixes — PASS; no reachable P1/P2 - remained. - -## Follow-ups - -- Add an explicit snapshot/cursor reseed contract for a Gateway `cursor expired` - response in a separate transport-resilience task. That pre-existing recovery gap is - broader than the AI activity presentation slice. - -## Promotion Candidates - -- Target canonical documents: ADR-007 and the client/Main transport architecture. -- Proposal: document operation-scoped AI activity as transient Renderer state fed by - Main-normalized public Gateway events; generic Gateway terminal events must settle - uncertain writes without changing operation identity. -- Evidence: fixed public stage projection, exact turn association, terminal/poll race - tests, stale-source and cross-Workspace tests, 67 passing focused tests, typecheck, - lint, and production build. -- Future impact: future Agents SDK lifecycle hooks can reuse the same UI seam, while - canonical Design state continues to come only from Workspace snapshots/events. -- Semantic conflicts: none identified; this preserves ADR-007's Main ownership and - temporary Renderer-state boundaries. -- Human confirmation required: yes, during a future integration-mode promotion to - canonical architecture documentation. diff --git a/.project-docs/30-worklog/tasks/20260907-integrate-design-agent-activity-b7d3f1a8.md b/.project-docs/30-worklog/tasks/20260907-integrate-design-agent-activity-b7d3f1a8.md new file mode 100644 index 0000000..670eb32 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-integrate-design-agent-activity-b7d3f1a8.md @@ -0,0 +1,64 @@ +# Task: Integrate AI Design agent activity UI + +## Identity + +- Task ID: 20260907-integrate-design-agent-activity-b7d3f1a8 +- Mode: Integration +- Branch: codex/20260907-integrate-design-agent-activity-b7d3f1a8-integrate-design-agent-activity +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260907-integrate-design-agent-activity-b7d3f1a8 +- Base commit: da29294979da4ce041edf992b590ee6ea170a44f +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate client source commit `81b524a02c2cf51304090afa8fb89d27c72c5d2b` from exact base `da29294979da4ce041edf992b590ee6ea170a44f`. +- Promote the operation-scoped AI Design activity projection into canonical client memory. +- Verify Main event normalization, Renderer reconciliation, conversation rendering, and existing Design streaming behavior. + +## Intent And Constraints + +- Render only the server's fixed public lifecycle stages beneath the matching optimistic user message. +- Keep `design.assistant.delta` as the single provisional assistant-text stream and the right-side Living Form as the Current Specification projection. +- Never render raw chain-of-thought, prompt/provider/tool payloads, or a duplicate global “整理进度” banner. +- Preserve stable operation identity and existing unknown-result reconciliation. +- Do not touch or adopt unrelated changes in the root worktree. + +## Outcome + +- Fast-forwarded the exact source commit from the recorded base without conflict. +- Integrated Main-normalized public Design activity, operation-scoped Renderer + reconciliation, terminal-event convergence, and the conversation-attached activity + panel while preserving optimistic user messages and canonical assistant replacement. +- Fixed two integration-review findings: a later chat no longer erases earlier + completed/unknown activity, and each pending operation now renders its user message, + activity, and provisional assistant stream as one ordered group. +- Promoted the accepted transient-activity boundary into ADR-007, architecture maps, + data flow, domain rules, and current integrated state. +- No server contract, database, billing, Quote/Task, navigation, package, deployment, + publication, or Provider behavior changed. + +## Verification + +- `pnpm exec vitest run tests/unit/image-workspace-store.test.ts + tests/unit/image-canvas-page.test.tsx + tests/unit/works-square-design-workspace.test.ts + tests/unit/image-workspace-route.test.ts` — 69 passed. +- `pnpm exec tsc --noEmit` — passed. +- Focused ESLint across all eight changed TypeScript/TSX files — passed. +- `pnpm run build:vite` — passed; only existing chunk-size, dynamic-import, and + stale Browserslist-data warnings were emitted. +- `git diff --check` — passed. +- Independent integration review — PASS after the two multi-operation findings were + fixed and covered. + +## Follow-ups + +- Add an explicit snapshot/cursor reseed contract for a Gateway `cursor expired` + response in a separate transport-resilience task. + +## Promotion Candidates + +- Applied: ADR-007, system overview, module map, data flow, domain rules, and current + integrated state now define operation-scoped public activity as transient + presentation rather than Design authority or model reasoning. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index 59ea49f..7aafa36 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -125,6 +125,7 @@ - Direction projection 是 Specification 真值。event cursor、assistant delta、Task progress 与 Asset event 只用于传输/资源收敛,不得推进或覆盖 canonical specification revision。 - 用户提交 chat 后,客户端可以立即把同一个 pending operation 投影为临时用户气泡,并明确显示“发送中”或“正在确认”;只有服务端返回的 canonical turn 才进入 conversation timeline。确定失败时必须让原草稿重新可编辑,不得把临时投影持久化为第二条消息。 - `design.assistant.delta` 只用于未完成回复的传输和结果收敛,不得进入 canonical conversation timeline。客户端可以把它临时显示为与同一 pending chat identity 绑定的单个未完成助手气泡;确定收敛后必须由 canonical turn 替换,unknown outcome 则保留原 identity 与已有片段。不得把片段伪装为完成回复、持久化为第二条消息、因其他 operation 更新而全局清除,或另加独立的整理进度栏。AI 当前整理出的设计理解仍由右侧 Current Specification 投影承载;连接重叠或事件重放按 connection generation 与 `chunkIndex` 去重收敛。 +- `design.assistant.progress` 只能由 Main 把服务端闭集 stage 归一为固定、通俗的活动文案,并附着在同一 pending chat identity 的 optimistic user bubble 下。它是可更新、可折叠的瞬时 presentation state,不是消息、Current Specification、任务授权或模型思考过程;不得显示 chain-of-thought、prompt、Provider response、tool argument、任意服务端文案或未验证模型文本。终态、重连和重放必须按原 operation identity 收敛。 - Main 只向 Renderer 投影已知错误码的固定中文提示,未知上游错误文本必须脱敏为通用提示;Works Token、stream ticket、provider internals 留在 Main/服务端。 - AI 绘画 Workspace JSON 请求与 shared Works token refresh 必须覆盖取凭据、发请求和读取响应 body 的完整 30 秒 deadline;即使底层 transport 忽略 abort,调用方也必须确定性结束为 `504 DESIGN_WORKSPACE_REQUEST_TIMEOUT` 并释放共同等待者。Electron-to-Node 透明 fallback 仅允许 `GET`/`HEAD`/`OPTIONS`;mutation 不得因 transport failure 被隐式重放,任何重试必须由上层显式幂等合同授权。 - `design.quote.request` 必须绑定 exact current Specification revision 并返回 immutable Quote;任何 production-meaning edit 都需要新 revision 与新 Quote。对话或 Current Specification 就绪态可以引导用户请求并查看 Quote,桌面端可定位到报价区、移动端可打开报价面板,但该动作不得创建 Task。 @@ -169,4 +170,4 @@ ## Last Reviewed -2026-09-06 +2026-09-07 diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 63df463..fbf5dcb 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -172,6 +172,9 @@ export function DesignConversationPane({ const text = assistantStreams[message.operationId]?.trim(); return text ? [{ ...message, text }] : []; }); + const streamingReplyByOperationId = new Map( + streamingReplies.map((reply) => [reply.operationId, reply.text]), + ); const projectedDraft = chatDraft.trim() !== '' && pendingChatMessages.some((message) => message.message === chatDraft.trim()); const composerDraft = projectedDraft ? '' : chatDraft; @@ -290,6 +293,7 @@ export function DesignConversationPane({ {pendingChatMessages.map((message) => { const linkedActivity = activityByOperationId.get(message.operationId); + const streamingReply = streamingReplyByOperationId.get(message.operationId); return (
)} + {streamingReply && ( +
+ {streamingReply} +
+ )}
); })} - {streamingReplies.map((reply) => ( -
- {reply.text} -
- ))} - {canRequestQuote && pendingChatMessages.length === 0 && (
((set, get) => const assistantActivities = isChatCommand(command) ? existingActivity ? { + ...state.assistantActivities, [operation.id]: { ...existingActivity, status: 'active' as const, }, } - : {} + : state.assistantActivities : state.assistantActivities; return { pendingOperations: { ...state.pendingOperations, [operation.id]: operation }, diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index db9893f..9549881 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -198,6 +198,63 @@ describe('youth AI Design Canvas page', () => { .toBeInTheDocument(); }); + it('keeps each pending reply beside the user message that started it', () => { + prepareWorkspace({ turns: [] }); + useImageWorkspaceStore.setState({ + 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: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + 'operation-chat-2': { + id: 'operation-chat-2', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-2', + input: { kind: 'chat', message: '再补充一只小兔子' }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantStreams: { + 'operation-chat-1': '小熊会站在月亮旁边。', + }, + }); + + render(); + + const conversation = screen.getByTestId('image-workspace-conversation'); + const firstMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); + const firstReply = within(conversation) + .getByTestId('streaming-design-assistant-operation-chat-1'); + const secondMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-2'); + expect(firstMessage.compareDocumentPosition(firstReply) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(firstReply.compareDocumentPosition(secondMessage) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + it('shows public AI activity under the submitted message and collapses it beside the final turn', async () => { const message = '画一只在月球踢球的熊猫'; const { workspace } = prepareWorkspace({ turns: [] }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 698b8af..49bb83d 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -1206,6 +1206,64 @@ describe('V2 Living Form store', () => { .toMatchObject({ status: 'unknown', steps: [{ stage: 'reviewing_context' }] }); }); + it('preserves an earlier unknown activity when another chat starts', async () => { + await loadedStore(); + const submission = deferred>>(); + operationIdMock.mockReturnValueOnce('operation-chat-2'); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.setState({ + chatDraft: '再补充一只小兔子', + 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: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }], + }, + }, + }); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + + expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({ + 'operation-chat-1': { + status: 'unknown', + steps: [{ stage: 'reviewing_context' }], + }, + }); + expect(useImageWorkspaceStore.getState().pendingOperations).toMatchObject({ + 'operation-chat-1': { status: 'unknown' }, + 'operation-chat-2': { status: 'submitting' }, + }); + + submission.resolve({ + clientOperationId: 'operation-chat-2', + runId: 'run-2', + workspace: designWorkspaceFixture(), + }); + await sendPromise; + }); + it('expires quote blockers only after the canonical Specification changes', async () => { await loadedStore(); const blocker = { From 4b894c7e320cdf829888f63f501d176f051be7d7 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 7 Sep 2026 12:36:12 +0800 Subject: [PATCH 3/3] docs: record AI Design activity main landing --- .project-docs/30-worklog/current-state.md | 2 +- ...907-land-design-agent-activity-e3b7a1c9.md | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .project-docs/30-worklog/tasks/20260907-land-design-agent-activity-e3b7a1c9.md diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 10dc250..de71f67 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -7,7 +7,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre - AI Design operation activity source `81b524a02c2cf51304090afa8fb89d27c72c5d2b` from task `20260907-design-agent-activity-client-b6d913e4` is integrated by task - `20260907-integrate-design-agent-activity-b7d3f1a8`. Electron Main now + `20260907-land-design-agent-activity-e3b7a1c9`. Electron Main now normalizes the server's four closed public stages into fixed youth-safe copy; Renderer attaches one transient, update-in-place `AI 处理过程` panel to the exact optimistic user message and collapses completed work without creating a chat Turn diff --git a/.project-docs/30-worklog/tasks/20260907-land-design-agent-activity-e3b7a1c9.md b/.project-docs/30-worklog/tasks/20260907-land-design-agent-activity-e3b7a1c9.md new file mode 100644 index 0000000..f301b0b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-land-design-agent-activity-e3b7a1c9.md @@ -0,0 +1,63 @@ +# Task: Land AI Design agent activity UI on main + +## Identity + +- Task ID: 20260907-land-design-agent-activity-e3b7a1c9 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 7bdb42f26c60747fbabf3bb3a00e3df3477a46a3 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Record and verify the root `main` landing of reviewed candidate + `7bdb42f26c60747fbabf3bb3a00e3df3477a46a3`. +- Promote this root landing task as the current-state integration owner while + preserving the retired candidate task record as immutable evidence. +- Preserve the three pre-existing untracked task records byte-for-byte and keep them + outside this task's commit. + +## Intent And Constraints + +- The candidate was fast-forwarded only after exact branch, ancestry, dirty-path, and + fingerprint preflight; do not rewrite its product tree. +- Do not reset, stash, clean, stage, or edit unrelated work. +- Reuse candidate tests and independent review; run the final root document, drift, + ancestry, fingerprint, and status gates that can detect landing-specific failures. + +## Outcome + +- `main` was fast-forwarded from + `da29294979da4ce041edf992b590ee6ea170a44f` to the exact reviewed candidate + `7bdb42f26c60747fbabf3bb3a00e3df3477a46a3`. +- The landed tree contains Main-normalized public Design activity, + operation-scoped Renderer reconciliation, terminal convergence, and ordered + optimistic-user/activity/provisional-reply groups. +- The three adopted task records retained preflight fingerprints + `728a9d4a20d4f0c4fee82177807c07e2a207264c`, + `2df2c08a55c2670f99588d3511deb1e533a6c97c`, and + `0538a686602b1ee7ef907880d65f5cf176b9916f`; they remain excluded from this task. + +## Verification + +- Candidate evidence: 69 focused tests, TypeScript, focused ESLint, + `pnpm run build:vite`, and `git diff --check` passed. +- Independent client activity integration review: PASS after the two + multi-operation findings were fixed and covered. +- Final root `check_project_docs.py`, task-aware `check_doc_drift.py`, and + `git diff --check` passed. `main` remained at the exact candidate, the candidate is + an ancestor, no non-document delta exists, and all three adopted fingerprints + remained unchanged. + +## Follow-ups + +- Add an explicit snapshot/cursor reseed contract for a Gateway `cursor expired` + response in a separate transport-resilience task. + +## Promotion Candidates + +- Applied: ADR-007, system overview, module map, data flow, domain rules, and current + integrated state define operation-scoped public activity as transient presentation, + not Design authority or model reasoning.