diff --git a/.project-docs/30-worklog/tasks/20260819-history-load-stall-a92d.md b/.project-docs/30-worklog/tasks/20260819-history-load-stall-a92d.md new file mode 100644 index 0000000..06343bd --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-history-load-stall-a92d.md @@ -0,0 +1,49 @@ +# Task: Fix AI design history loading stall + +## Identity + +- Task ID: 20260819-history-load-stall-a92d +- Mode: Feature +- Branch: codex/20260819-history-load-stall-a92d-history-load-stall +- Worktree: D:\mk-history-load-stall-a92d +- Base commit: abece81fdb178711d4dcbe1f87b987f54d71d6dd +- Owner: codex-root +- Status: Completed + +## Scope + +- Replace full-history AI Design conversation loading with a cursor-paginated latest-message flow. +- Ensure workspace history becomes visible before generation-task reconciliation completes. +- Make rapid conversation switching safe when an event-stream connection is still pending. +- Update the local and Works Square adapters, shared boundary, main-process route, renderer store/canvas, and focused tests as needed. + +## Intent And Constraints + +- Initial conversation reads return the newest 10 message records; scrolling upward requests older pages and preserves viewport position. +- Do not retain or transmit an unbounded message history through the normal conversation API, mutation payloads, or snapshots. +- Preserve the existing workspace/conversation identity checks and do not apply stale responses after a switch. +- A pending connection attempt is treated as owned until it settles or is explicitly aborted; returning A -> B -> A must not create duplicate pending streams for A. +- Server and desktop client contracts must remain explicit and bounded; page cursor contents are opaque to the renderer. + +## Outcome + +- The renderer shows a selected Conversation as soon as its Workspace response arrives; delayed or failed generation-task reconciliation can no longer keep the history view on its loading state. +- Conversation history is cursor-paginated end to end: the initial page is the newest 10 messages, scrolling upward loads one older page at a time, de-duplicates stable message IDs, and retains viewport position. +- Local and Works Square adapters expose the same page metadata. The Main route forwards the opaque cursor without interpreting it. +- Rapid A → B → A switching reuses an in-flight A event-stream attempt instead of opening another non-cancellable request. Renderer disconnection while Main is still opening a relay now closes the eventual subscription, and an upstream WebSocket open is bounded by a 10-second timeout. + +## Verification + +- `pnpm test` — 183 files and 2167 tests passed. +- `pnpm exec vitest run tests/unit/image-workspace-api.test.ts tests/unit/image-workspace-store.test.ts tests/unit/local-image-workspace.test.ts tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-route.test.ts tests/unit/image-canvas-page.test.tsx --reporter=dot` — 154 passed. The Canvas suite still reports three pre-existing React `act(...)` warnings in its stale-auth test. +- `pnpm run typecheck` — passed. +- `pnpm exec eslint electron/api/routes/image-workspace.ts electron/image-workspace/local-workspace.ts electron/image-workspace/module.ts electron/image-workspace/works-square-workspace.ts shared/image-workspace.ts src/lib/image-workspace.ts src/pages/ImageCanvas/index.tsx src/stores/image-workspace.ts tests/unit/image-workspace-route.test.ts tests/unit/image-workspace-store.test.ts tests/unit/local-image-workspace.test.ts tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-api.test.ts` — passed. +- `git diff --check` — passed. + +## Follow-ups + +- Main-branch promotion is pending release of the repository-wide integration lock held by task `20260819-merge-performance-package-6e5a`. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 7370b25..b321c24 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -18,7 +18,10 @@ import { type DesignRenameWorkspaceInput, type DesignSubmitMessageInput, } from '../../../shared/image-workspace'; -import { DesignWorkspaceModuleError } from '../../image-workspace/module'; +import { + DesignWorkspaceModuleError, + type DesignWorkspaceEventSubscription, +} from '../../image-workspace/module'; import type { HostApiContext } from '../context'; import { flushStreamingHeaders, @@ -328,19 +331,25 @@ async function relayWorkspaceEvents( } const header = req.headers['last-event-id']; const afterEventId = Array.isArray(header) ? header[0] : header; - const subscription = await ctx.imageWorkspace.openWorkspaceEvents({ - workspaceId, - conversationId, - ...(afterEventId ? { afterEventId } : {}), - }); + let subscription: DesignWorkspaceEventSubscription | null = null; let closed = false; const close = () => { if (closed) return; closed = true; - subscription.close(); + subscription?.close(); }; res.once('close', close); try { + const opened = await ctx.imageWorkspace.openWorkspaceEvents({ + workspaceId, + conversationId, + ...(afterEventId ? { afterEventId } : {}), + }); + if (closed) { + opened.close(); + return; + } + subscription = opened; res.statusCode = 200; res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); @@ -456,7 +465,13 @@ export async function handleImageWorkspaceRoutes( && segments[0] === 'workspaces' && segments[2] === 'conversations' && req.method === 'GET') { - sendData(res, await ctx.imageWorkspace.getConversation(segments[1], segments[3])); + const before = url.searchParams.get('before'); + sendData( + res, + before === null + ? await ctx.imageWorkspace.getConversation(segments[1], segments[3]) + : await ctx.imageWorkspace.getConversation(segments[1], segments[3], before), + ); return true; } diff --git a/electron/image-workspace/local-workspace.ts b/electron/image-workspace/local-workspace.ts index 0a09172..09b5ff2 100644 --- a/electron/image-workspace/local-workspace.ts +++ b/electron/image-workspace/local-workspace.ts @@ -257,6 +257,7 @@ const LOCAL_CAPABILITIES: DesignCapabilities = { image: true, video: true, }; +const LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE = 10; type PersistedWorkspace = DesignWorkspaceSummary & { clientWorkspaceId: string; @@ -542,9 +543,13 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { async getConversation( workspaceId: string, conversationId: string, + before?: string, ): Promise { const state = await this.load(); - return clone(this.requireConversation(state, workspaceId, conversationId)); + return this.conversationView( + this.requireConversation(state, workspaceId, conversationId), + before, + ); } renameWorkspace(input: DesignRenameWorkspaceInput): Promise { @@ -1081,14 +1086,34 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { }); } - private conversationView(conversation: PersistedConversation): DesignConversation { + private conversationView( + conversation: PersistedConversation, + before?: string, + ): DesignConversation { const { clientConversationId: _clientId, requestHashes: _requestHashes, ...view } = conversation; + const endIndex = before === undefined + ? conversation.messages.length + : conversation.messages.findIndex((message) => message.id === before); + if (endIndex < 0) { + throw new LocalImageWorkspaceError( + 422, + 'conversation_history_cursor_invalid', + '历史会话分页位置已失效,请重新打开会话', + ); + } + const startIndex = Math.max(0, endIndex - LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE); + const messages = conversation.messages.slice(startIndex, endIndex); return clone({ ...view, + messages, + messagePage: { + hasOlder: startIndex > 0, + nextBefore: startIndex > 0 ? messages[0]?.id ?? null : null, + }, latestMessagePreview: latestMessagePreview(conversation.messages), }); } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index edb0c6b..1e11a04 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -53,7 +53,11 @@ export interface DesignWorkspaceModule { renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; createConversation(input: DesignCreateConversationInput): Promise; - getConversation(workspaceId: string, conversationId: string): Promise; + getConversation( + workspaceId: string, + conversationId: string, + before?: string, + ): Promise; submitMessage(input: DesignSubmitMessageInput): Promise; updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise; confirmGeneration(input: DesignConfirmGenerationInput): Promise; diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 5006832..9f88ad6 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -86,6 +86,7 @@ type ServerQuote = { }; type ServerMessage = { + message_id?: string; role: DesignMessage['role']; kind: DesignMessage['kind']; text: string; @@ -119,6 +120,10 @@ type ServerConversationSummary = { type ServerConversation = ServerConversationSummary & { messages: ServerMessage[]; + message_page?: { + has_older: boolean; + next_before: string | null; + }; }; type ServerWorkspace = ServerWorkspaceSummary & { @@ -264,6 +269,7 @@ type AgentCommandChannel = { }; const AGENT_WEBSOCKET_OPEN = 1; +const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000; const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000; const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000; @@ -393,7 +399,8 @@ function mapConversationSummary( function mapConversation(conversation: ServerConversation): DesignConversation { const messages = conversation.messages.map((message, index) => ({ - id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`, + id: message.message_id + ?? `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`, role: message.role, kind: message.kind, text: message.text, @@ -408,6 +415,12 @@ function mapConversation(conversation: ServerConversation): DesignConversation { ?? normalizeMessagePreview(conversation.latest_message_preview) ?? normalizeMessagePreview(conversation.brief.summary), messages, + messagePage: conversation.message_page + ? { + hasOlder: conversation.message_page.has_older, + nextBefore: conversation.message_page.next_before, + } + : undefined, }; } @@ -935,9 +948,11 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { async getConversation( workspaceId: string, conversationId: string, + before?: string, ): Promise { + const query = before ? `?before=${encodeURIComponent(before)}` : ''; const conversation = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`, ); this.rememberConversation(conversation); return mapConversation(conversation); @@ -1328,6 +1343,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { let ending = false; let settled = false; let heartbeat: ReturnType | null = null; + let openedTimeout: ReturnType | null = null; let unregister = () => undefined; let resolveOpened: () => void = () => undefined; let rejectOpened: (error: unknown) => void = () => undefined; @@ -1349,6 +1365,8 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { socket.onclose = null; if (heartbeat !== null) clearInterval(heartbeat); heartbeat = null; + if (openedTimeout !== null) clearTimeout(openedTimeout); + openedTimeout = null; unregister(); if (didOpen) { this.unregisterAgentCommandChannel(sessionId, commandChannel); @@ -1450,6 +1468,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { input.conversationId, close, ); + openedTimeout = setTimeout(() => { + if (ending || didOpen) return; + beginEnd(new DesignWorkspaceModuleError( + 504, + 'DESIGN_EVENT_STREAM_TIMEOUT', + 'AI 设计任务状态连接超时,请稍后重试', + )); + try { + socket.close(1000, 'Timed out opening design event stream'); + } catch { + // settle below remains authoritative when the transport cannot close. + } + }, AGENT_WEBSOCKET_OPEN_TIMEOUT_MS); await opened; return { events: queue.events, diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index c1a4594..4647293 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -105,6 +105,13 @@ export type DesignConversationSummary = { export type DesignConversation = DesignConversationSummary & { messages: DesignMessage[]; + /** Cursor for the next older message page; absent only for legacy adapters. */ + messagePage?: DesignConversationMessagePage; +}; + +export type DesignConversationMessagePage = { + hasOlder: boolean; + nextBefore: string | null; }; export type DesignAsset = { diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 0efefd4..fad93a2 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -160,9 +160,11 @@ export function createImageWorkspaceConversation( export function fetchImageWorkspaceConversation( workspaceId: string, conversationId: string, + before?: string, ): Promise { + const query = before ? `?before=${encodeURIComponent(before)}` : ''; return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`, ); } diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 1e524d1..5857c22 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -930,6 +930,8 @@ export function ImageCanvas() { const conversation = useImageWorkspaceStore((state) => state.conversation); const tasks = useImageWorkspaceStore((state) => state.tasks); const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn); + const loadingOlderMessages = useImageWorkspaceStore((state) => state.loadingOlderMessages); + const olderMessagesError = useImageWorkspaceStore((state) => state.olderMessagesError); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream); @@ -937,6 +939,7 @@ export function ImageCanvas() { const markTaskStreamNeedsReconciliation = useImageWorkspaceStore( (state) => state.markTaskStreamNeedsReconciliation, ); + const loadOlderMessages = useImageWorkspaceStore((state) => state.loadOlderMessages); const sendMessage = useImageWorkspaceStore((state) => state.sendMessage); const updateGenerationQuote = useImageWorkspaceStore( (state) => state.updateGenerationQuote, @@ -966,6 +969,7 @@ export function ImageCanvas() { const conversationEndRef = useRef(null); const conversationWindowStartRef = useRef(null); const conversationShouldStickRef = useRef(true); + const olderMessagesRequestRef = useRef(false); const promptRef = useRef(null); const referenceUploadInputRef = useRef(null); const promptSelectionRef = useRef({ start: 0, end: 0 }); @@ -1111,6 +1115,7 @@ export function ImageCanvas() { useEffect(() => { conversationWindowStartRef.current = null; conversationShouldStickRef.current = true; + olderMessagesRequestRef.current = false; setConversationWindowStart(null); setPrompt(''); setReferenceAssets([]); @@ -1162,6 +1167,22 @@ export function ImageCanvas() { setConversationWindowStart(null); }, [conversationMessages.length]); + const requestOlderMessages = useCallback(() => { + const element = conversationScrollRef.current; + if (!element || olderMessagesRequestRef.current || !conversation?.messagePage?.hasOlder) return; + const previousHeight = element.scrollHeight; + olderMessagesRequestRef.current = true; + conversationShouldStickRef.current = false; + void loadOlderMessages().finally(() => { + olderMessagesRequestRef.current = false; + window.requestAnimationFrame(() => { + const nextElement = conversationScrollRef.current; + if (!nextElement) return; + nextElement.scrollTop += nextElement.scrollHeight - previousHeight; + }); + }); + }, [conversation?.messagePage?.hasOlder, loadOlderMessages]); + const handleConversationScroll = useCallback(() => { const element = conversationScrollRef.current; if (!element) return; @@ -1177,11 +1198,13 @@ export function ImageCanvas() { if (!nextElement) return; nextElement.scrollTop += nextElement.scrollHeight - previousHeight; }); + } else if (element.scrollTop < 96 && currentStart === 0) { + requestOlderMessages(); } conversationShouldStickRef.current = element.scrollHeight - element.scrollTop - element.clientHeight <= CANVAS_BOTTOM_THRESHOLD_PX; - }, [conversationMessages.length]); + }, [conversationMessages.length, requestOlderMessages]); const repriceQuote = async ( requestedQuoteId: string, @@ -1787,9 +1810,30 @@ export function ImageCanvas() { setConversationWindowStart(0); }} > - 加载更早消息({effectiveConversationWindowStart}) + 显示已加载的更早消息({effectiveConversationWindowStart}) ) : null} + {effectiveConversationWindowStart === 0 && loadingOlderMessages ? ( +
+ + 正在读取更早消息… +
+ ) : null} + {effectiveConversationWindowStart === 0 + && !loadingOlderMessages + && olderMessagesError + && conversation?.messagePage?.hasOlder ? ( + + ) : null} {windowedConversationMessages.map((message) => { const assistant = message.role === 'assistant'; return ( diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 05c8727..3acdaf4 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -65,6 +65,8 @@ type ImageWorkspaceState = { deletingWorkspaceId: string | null; tasks: DesignGenerationTask[]; pendingTurn: PendingDesignTurn | null; + loadingOlderMessages: boolean; + olderMessagesError: string | null; taskStreamState: ImageWorkspaceTaskStreamState; error: string | null; load: () => Promise; @@ -76,6 +78,7 @@ type ImageWorkspaceState = { selectConversation: (conversationId: string) => Promise; refreshWorkspace: () => Promise; refreshConversation: () => Promise; + loadOlderMessages: () => Promise; refreshTasks: () => Promise; connectTaskStream: () => void; markTaskStreamNeedsReconciliation: () => void; @@ -101,7 +104,7 @@ const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250; let activeTaskEventSource: EventSource | null = null; let activeTaskEventWorkspaceId: string | null = null; let activeTaskEventConversationId: string | null = null; -let taskStreamGeneration = 0; +const pendingTaskEventConnections = new Map(); let taskFallbackTimer: ReturnType | null = null; const taskEventRevisions = new Map(); let workspaceLoadGeneration = 0; @@ -126,13 +129,16 @@ function taskRevisionKey(workspaceId: string, taskId: string): string { return `${workspaceId}:${taskId}`; } +function taskStreamKey(workspaceId: string, conversationId: string): string { + return `${workspaceId}:${conversationId}`; +} + function stopFallbackPolling(): void { if (taskFallbackTimer !== null) clearInterval(taskFallbackTimer); taskFallbackTimer = null; } function closeTaskEventSource(): void { - taskStreamGeneration += 1; stopFallbackPolling(); if (activeTaskEventSource) { activeTaskEventSource.onopen = null; @@ -345,33 +351,35 @@ function mergePendingUserMessage( return { ...conversation, messages }; } -function mergeUserMessagesFromCurrentConversation( +function mergeConversationMessagePages( current: DesignConversation | null, incoming: DesignConversation, + source: 'latest' | 'older', ): DesignConversation { if (!current || current.conversationId !== incoming.conversationId || current.workspaceId !== incoming.workspaceId) { return incoming; } - const missingUserMessages = current.messages.filter((message) => ( - message.role === 'user' - && !incoming.messages.some((candidate) => ( - candidate.role === 'user' - && candidate.turnRevision === message.turnRevision - && candidate.text === message.text - )) - )); - if (missingUserMessages.length === 0) return incoming; - - const messages = [...incoming.messages]; - for (const userMessage of missingUserMessages) { - const insertionIndex = messages.findIndex( - (message) => message.turnRevision >= userMessage.turnRevision, - ); - messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage); + const messagesById = new Map(); + const messageSources = source === 'older' + ? [incoming.messages, current.messages] + : [current.messages, incoming.messages]; + for (const messages of messageSources) { + for (const message of messages) messagesById.set(message.id, message); } - return { ...incoming, messages }; + const messages = [...messagesById.values()].sort((left, right) => ( + left.turnRevision - right.turnRevision + || (left.role === right.role ? left.id.localeCompare(right.id) : left.role === 'user' ? -1 : 1) + || left.createdAt.localeCompare(right.createdAt) + )); + return { + ...incoming, + messages, + messagePage: source === 'older' + ? incoming.messagePage + : current.messagePage ?? incoming.messagePage, + }; } function replaceGenerationQuote( @@ -473,13 +481,20 @@ export const useImageWorkspaceStore = create((set, get) => && activeTaskEventConversationId === conversationId && get().taskStreamState !== 'idle') return; closeTaskEventSource(); - const generation = taskStreamGeneration; activeTaskEventWorkspaceId = workspaceId; activeTaskEventConversationId = conversationId; set({ taskStreamState: 'connecting' }); + const connectionKey = taskStreamKey(workspaceId, conversationId); + if (pendingTaskEventConnections.has(connectionKey)) return; + const connectionAttempt = Symbol(connectionKey); + pendingTaskEventConnections.set(connectionKey, connectionAttempt); void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => { - if (generation !== taskStreamGeneration - || get().activeWorkspaceId !== workspaceId + if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) { + source.close(); + return; + } + pendingTaskEventConnections.delete(connectionKey); + if (get().activeWorkspaceId !== workspaceId || get().activeConversationId !== conversationId) { source.close(); return; @@ -518,9 +533,10 @@ export const useImageWorkspaceStore = create((set, get) => || snapshot.conversation.turnRevision >= state.conversation.turnRevision ? snapshot.conversation : state.conversation; - const resolvedConversation = mergeUserMessagesFromCurrentConversation( + const resolvedConversation = mergeConversationMessagePages( state.conversation, mergePendingUserMessage(conversation, state.pendingTurn), + 'latest', ); const workspace = upsertConversation( state.workspace @@ -607,8 +623,9 @@ export const useImageWorkspaceStore = create((set, get) => startFallbackPolling(); }; }).catch(() => { - if (generation !== taskStreamGeneration - || get().activeWorkspaceId !== workspaceId + if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) return; + pendingTaskEventConnections.delete(connectionKey); + if (get().activeWorkspaceId !== workspaceId || get().activeConversationId !== conversationId) return; set({ taskStreamState: 'degraded' }); startFallbackPolling(); @@ -668,7 +685,10 @@ export const useImageWorkspaceStore = create((set, get) => return workspace; }; - const applyConversation = (conversation: DesignConversation): DesignConversation => { + const applyConversation = ( + conversation: DesignConversation, + source: 'latest' | 'older' = 'latest', + ): DesignConversation => { let applied = conversation; set((state) => { if (state.conversation?.conversationId === conversation.conversationId @@ -678,9 +698,10 @@ export const useImageWorkspaceStore = create((set, get) => applied = state.conversation; return state; } - const resolvedConversation = mergeUserMessagesFromCurrentConversation( + const resolvedConversation = mergeConversationMessagePages( state.conversation, mergePendingUserMessage(conversation, state.pendingTurn), + source, ); applied = resolvedConversation; const pendingTurn = state.pendingTurn?.workspaceId === resolvedConversation.workspaceId @@ -693,6 +714,8 @@ export const useImageWorkspaceStore = create((set, get) => activeConversationId: resolvedConversation.conversationId, conversation: resolvedConversation, pendingTurn, + loadingOlderMessages: false, + olderMessagesError: null, error: null, }; }); @@ -711,9 +734,6 @@ export const useImageWorkspaceStore = create((set, get) => ) ? currentConversationId : workspace.conversations[0]?.conversationId ?? null; - const tasks = await fetchImageWorkspaceTasks(workspaceId); - if (loadGeneration !== workspaceLoadGeneration - || get().activeWorkspaceId !== workspaceId) return; set((state) => ({ status: 'ready', bootstrap: upsertSummary(state.bootstrap, workspace), @@ -721,9 +741,17 @@ export const useImageWorkspaceStore = create((set, get) => activeConversationId: conversationId, workspace, conversation: null, - tasks: sortTasks(tasks), + tasks: [], + pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, error: null, })); + void fetchImageWorkspaceTasks(workspaceId).then((tasks) => { + if (loadGeneration !== workspaceLoadGeneration + || get().activeWorkspaceId !== workspaceId) return; + set({ tasks: sortTasks(tasks) }); + }).catch(() => undefined); if (!conversationId) return; const selectionGeneration = ++conversationSelectionGeneration; const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId); @@ -760,6 +788,8 @@ export const useImageWorkspaceStore = create((set, get) => deletingWorkspaceId: null, tasks: [], pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, taskStreamState: 'idle', error: null, @@ -861,6 +891,8 @@ export const useImageWorkspaceStore = create((set, get) => activeConversationId: conversation.conversationId, conversation: null, pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, taskStreamState: 'idle', error: null, }); @@ -962,6 +994,8 @@ export const useImageWorkspaceStore = create((set, get) => creatingConversation: false, tasks: [], pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, taskStreamState: 'idle', error: null, }); @@ -990,6 +1024,8 @@ export const useImageWorkspaceStore = create((set, get) => activeConversationId: conversationId, conversation: null, pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, taskStreamState: 'idle', error: null, }); @@ -1047,6 +1083,35 @@ export const useImageWorkspaceStore = create((set, get) => } }, + loadOlderMessages: async () => { + const workspaceId = get().activeWorkspaceId; + const conversation = get().conversation; + const before = conversation?.messagePage?.nextBefore; + if (!workspaceId || !conversation || !conversation.messagePage?.hasOlder || !before) { + return conversation ?? null; + } + const conversationId = conversation.conversationId; + const selectionGeneration = conversationSelectionGeneration; + set({ loadingOlderMessages: true, olderMessagesError: null }); + try { + const older = await fetchImageWorkspaceConversation(workspaceId, conversationId, before); + if (selectionGeneration !== conversationSelectionGeneration + || get().activeWorkspaceId !== workspaceId + || get().activeConversationId !== conversationId) return null; + return applyConversation(older, 'older'); + } catch (error) { + if (selectionGeneration === conversationSelectionGeneration + && get().activeWorkspaceId === workspaceId + && get().activeConversationId === conversationId) { + set({ + loadingOlderMessages: false, + olderMessagesError: handleRequestError(error), + }); + } + return null; + } + }, + refreshTasks: async () => { const workspaceId = get().activeWorkspaceId; if (!workspaceId) { @@ -1306,6 +1371,7 @@ export const useImageWorkspaceStore = create((set, get) => generationQuoteUpdateSequence += 1; reconcileTaskStateOnNextConnect = false; stopTaskStream(); + pendingTaskEventConnections.clear(); set({ status: 'idle', bootstrap: null, @@ -1317,6 +1383,8 @@ export const useImageWorkspaceStore = create((set, get) => deletingWorkspaceId: null, tasks: [], pendingTurn: null, + loadingOlderMessages: false, + olderMessagesError: null, taskStreamState: 'idle', error: null, }); diff --git a/tests/unit/image-workspace-api.test.ts b/tests/unit/image-workspace-api.test.ts index bfae220..f90eaec 100644 --- a/tests/unit/image-workspace-api.test.ts +++ b/tests/unit/image-workspace-api.test.ts @@ -104,6 +104,11 @@ describe('AI design renderer API boundary', () => { 'client-conversation-one', ); await fetchImageWorkspaceConversation('workspace/one', 'conversation/one'); + await fetchImageWorkspaceConversation( + 'workspace/one', + 'conversation/one', + 'older page/+', + ); expect(hostApiFetchMock).toHaveBeenNthCalledWith( 1, @@ -121,6 +126,11 @@ describe('AI design renderer API boundary', () => { '/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone', {}, ); + expect(hostApiFetchMock).toHaveBeenNthCalledWith( + 3, + '/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=older%20page%2F%2B', + {}, + ); }); it('sends conversation turns with the current turn revision and no provider settings', async () => { diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index 714be93..92622a0 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -51,6 +51,14 @@ function createRequest(method: string, body?: unknown): IncomingMessage { return request; } +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + class MediaResponse extends Writable { statusCode = 0; readonly chunks: Buffer[] = []; @@ -224,6 +232,21 @@ describe('AI design Main route boundary', () => { data: { conversationId: 'conversation/one' }, }); + const olderConversationResponse = createResponse(); + await handleImageWorkspaceRoutes( + createRequest('GET'), + olderConversationResponse.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=opaque-cursor', + ), + ctx, + ); + expect(workspace.getConversation).toHaveBeenLastCalledWith( + 'workspace/one', + 'conversation/one', + 'opaque-cursor', + ); + const updateQuoteResponse = createResponse(); await handleImageWorkspaceRoutes( createRequest('PATCH', { @@ -637,4 +660,33 @@ describe('AI design Main route boundary', () => { ].join('\n')); expect(close).toHaveBeenCalledOnce(); }); + + it('closes an event subscription that finishes opening after the renderer disconnects', async () => { + const opening = deferred<{ + events: AsyncIterable; + close(): void; + }>(); + const close = vi.fn(); + const openWorkspaceEvents = vi.fn().mockReturnValue(opening.promise); + const response = new MediaResponse(); + const handled = handleImageWorkspaceRoutes( + createRequest('GET'), + response as unknown as ServerResponse, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events', + ), + { imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext, + ); + + await vi.waitFor(() => expect(openWorkspaceEvents).toHaveBeenCalledOnce()); + response.emit('close'); + opening.resolve({ + events: (async function* () {})(), + close, + }); + + await expect(handled).resolves.toBe(true); + expect(close).toHaveBeenCalledOnce(); + expect(response.statusCode).toBe(0); + }); }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 21d983f..4f56027 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -114,6 +114,31 @@ function conversation( }; } +function conversationMessages(revisions: number[]): DesignConversation['messages'] { + return revisions.flatMap((turnRevision) => ([ + { + id: `turn-${turnRevision}:user`, + role: 'user' as const, + kind: 'user' as const, + text: `用户消息 ${turnRevision}`, + quickReplies: [], + generationQuote: null, + turnRevision, + createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`, + }, + { + id: `turn-${turnRevision}:assistant`, + role: 'assistant' as const, + kind: 'reply' as const, + text: `助手消息 ${turnRevision}`, + quickReplies: [], + generationQuote: null, + turnRevision, + createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`, + }, + ])); +} + function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace { const { messages: _messages, ...conversationSummary } = conversation(workspaceId); return { @@ -400,6 +425,91 @@ describe('AI design task event store', () => { expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce(); }); + it('reuses an unresolved Conversation stream when switching A to B to A', async () => { + const pendingA = deferred(); + const sourceB = new MockEventSource(); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 0, + 'conversation-two', + ); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + openImageWorkspaceTaskEventsMock + .mockReturnValueOnce(pendingA.promise) + .mockResolvedValueOnce(sourceB as unknown as EventSource); + + await useImageWorkspaceStore.getState().load(); + await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock) + .toHaveBeenCalledWith('workspace-one', 'conversation-one')); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + await useImageWorkspaceStore.getState().selectConversation('conversation-one'); + + expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2); + expect(openImageWorkspaceTaskEventsMock).toHaveBeenLastCalledWith( + 'workspace-one', + 'conversation-two', + ); + + const sourceA = new MockEventSource(); + pendingA.resolve(sourceA as unknown as EventSource); + await vi.waitFor(() => expect(sourceA.onopen).not.toBeNull()); + }); + + it('shows the Conversation before a delayed task refresh settles', async () => { + const source = new MockEventSource(); + const delayedTasks = deferred(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceTasksMock.mockReturnValueOnce(delayedTasks.promise); + + await useImageWorkspaceStore.getState().load(); + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + status: 'ready', + conversation: { conversationId: 'conversation-one' }, + tasks: [], + }); + delayedTasks.resolve([task]); + await vi.waitFor(() => expect(useImageWorkspaceStore.getState().tasks).toEqual([task])); + }); + + it('prepends the next older page without losing the latest messages', async () => { + const source = new MockEventSource(); + const latest = { + ...conversation('workspace-one', 6), + messages: conversationMessages([2, 3, 4, 5, 6]), + messagePage: { hasOlder: true, nextBefore: 'cursor-before-turn-2' }, + }; + const older = { + ...conversation('workspace-one', 6), + messages: conversationMessages([1]), + messagePage: { hasOlder: false, nextBefore: null }, + }; + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceConversationMock + .mockResolvedValueOnce(latest) + .mockResolvedValueOnce(older); + + await useImageWorkspaceStore.getState().load(); + await useImageWorkspaceStore.getState().loadOlderMessages(); + + expect(fetchImageWorkspaceConversationMock).toHaveBeenLastCalledWith( + 'workspace-one', + 'conversation-one', + 'cursor-before-turn-2', + ); + expect(useImageWorkspaceStore.getState().conversation).toMatchObject({ + messagePage: { hasOlder: false, nextBefore: null }, + }); + expect(useImageWorkspaceStore.getState().conversation?.messages.map((message) => ( + message.turnRevision + ))).toEqual([1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6]); + }); + it('recovers a committed generation task when the Agent Run fails afterward', async () => { const source = new MockEventSource(); openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); diff --git a/tests/unit/local-image-workspace.test.ts b/tests/unit/local-image-workspace.test.ts index de2ad27..04f76d3 100644 --- a/tests/unit/local-image-workspace.test.ts +++ b/tests/unit/local-image-workspace.test.ts @@ -286,6 +286,39 @@ describe('local AI design workspace', () => { })).resolves.toMatchObject({ turnRevision: 2 }); }); + it('pages local Conversation history in ten-message windows', async () => { + const service = createService(createTemporaryDirectory()); + const created = await service.createWorkspace({ + clientWorkspaceId: 'client-history-page', + title: '分页历史', + }); + const conversationId = created.conversations[0].conversationId; + + for (let revision = 0; revision < 6; revision += 1) { + await service.submitMessage({ + workspaceId: created.workspaceId, + conversationId, + clientTurnId: `history-page-turn-${revision + 1}`, + expectedTurnRevision: revision, + message: `第 ${revision + 1} 条设计需求`, + }); + } + + const latest = await service.getConversation(created.workspaceId, conversationId); + expect(latest.messages).toHaveLength(10); + expect(latest.messages.map((message) => message.turnRevision)) + .toEqual([2, 2, 3, 3, 4, 4, 5, 5, 6, 6]); + expect(latest.messagePage).toMatchObject({ hasOlder: true }); + + const older = await service.getConversation( + created.workspaceId, + conversationId, + latest.messagePage!.nextBefore!, + ); + expect(older.messages.map((message) => message.turnRevision)).toEqual([1, 1]); + expect(older.messagePage).toEqual({ hasOlder: false, nextBefore: null }); + }); + it('deletes a project while a task is queued and makes its task state inaccessible', async () => { const userDataDir = createTemporaryDirectory(); const service = createService(userDataDir); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index d62cd32..93255af 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -464,6 +464,41 @@ describe('Works Square AI design adapter', () => { ); }); + it('passes the opaque older-history cursor through and preserves server message ids', async () => { + const pagedConversation = { + ...serverConversation, + messages: [{ + ...serverConversation.messages[0], + message_id: 'persisted-turn-one:assistant', + }], + message_page: { + has_older: true, + next_before: 'opaque-older-page', + }, + }; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(pagedConversation)); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + }); + + const result = await adapter.getConversation( + 'workspace-one', + 'conversation-one', + 'opaque-older-page', + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one?before=opaque-older-page', + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(result.messages[0]?.id).toBe('persisted-turn-one:assistant'); + expect(result.messagePage).toEqual({ + hasOlder: true, + nextBefore: 'opaque-older-page', + }); + }); + it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => { const { medium: _medium, ...legacyBrief } = serverConversation.brief; const legacyConversation = { @@ -1148,6 +1183,46 @@ describe('Works Square AI design adapter', () => { } }); + it('fails a WebSocket event stream that never opens instead of leaving it pending', async () => { + vi.useFakeTimers(); + const { sockets, webSocketFactory } = scriptedSockets([{ open: false }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-open-timeout' }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-open-timeout/ws?ticket=ticket-open-timeout', + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + + try { + const opening = adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + const openingAssertion = expect(opening).rejects.toMatchObject({ + status: 504, + code: 'DESIGN_EVENT_STREAM_TIMEOUT', + }); + await vi.advanceTimersByTimeAsync(10_000); + + await openingAssertion; + expect(sockets).toHaveLength(1); + expect(sockets[0].readyState).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + it('maps a failed Run received from the connected WebSocket without polling', async () => { const { webSocketFactory } = scriptedSockets([{ open: true,