fix: stabilize ai design history switching

This commit is contained in:
2026-08-19 10:31:36 +08:00
parent abece81fdb
commit bf0b805d9f
14 changed files with 573 additions and 48 deletions

View File

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

View File

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

View File

@@ -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<DesignConversation> {
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<DesignWorkspace> {
@@ -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),
});
}

View File

@@ -53,7 +53,11 @@ export interface DesignWorkspaceModule {
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace>;
getWorkspace(workspaceId: string): Promise<DesignWorkspace>;
createConversation(input: DesignCreateConversationInput): Promise<DesignConversation>;
getConversation(workspaceId: string, conversationId: string): Promise<DesignConversation>;
getConversation(
workspaceId: string,
conversationId: string,
before?: string,
): Promise<DesignConversation>;
submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation>;
updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise<DesignGenerationQuote>;
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation>;

View File

@@ -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<DesignConversation> {
const query = before ? `?before=${encodeURIComponent(before)}` : '';
const conversation = await this.requestJson<ServerConversation>(
`/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<typeof setInterval> | null = null;
let openedTimeout: ReturnType<typeof setTimeout> | 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,

View File

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

View File

@@ -160,9 +160,11 @@ export function createImageWorkspaceConversation(
export function fetchImageWorkspaceConversation(
workspaceId: string,
conversationId: string,
before?: string,
): Promise<DesignConversation> {
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}`,
);
}

View File

@@ -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<HTMLDivElement | null>(null);
const conversationWindowStartRef = useRef<number | null>(null);
const conversationShouldStickRef = useRef(true);
const olderMessagesRequestRef = useRef(false);
const promptRef = useRef<HTMLTextAreaElement | null>(null);
const referenceUploadInputRef = useRef<HTMLInputElement | null>(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}
</button>
) : null}
{effectiveConversationWindowStart === 0 && loadingOlderMessages ? (
<div
data-testid="image-workspace-loading-older-messages"
className="mx-auto flex items-center gap-2 py-1 text-xs text-muted-foreground"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : null}
{effectiveConversationWindowStart === 0
&& !loadingOlderMessages
&& olderMessagesError
&& conversation?.messagePage?.hasOlder ? (
<button
type="button"
className="mx-auto block rounded-md border border-border/70 px-3 py-1.5 text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
onClick={requestOlderMessages}
>
</button>
) : null}
{windowedConversationMessages.map((message) => {
const assistant = message.role === 'assistant';
return (

View File

@@ -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<DesignWorkspaceBootstrap | null>;
@@ -76,6 +78,7 @@ type ImageWorkspaceState = {
selectConversation: (conversationId: string) => Promise<void>;
refreshWorkspace: () => Promise<DesignWorkspace | null>;
refreshConversation: () => Promise<DesignConversation | null>;
loadOlderMessages: () => Promise<DesignConversation | null>;
refreshTasks: () => Promise<DesignGenerationTask[]>;
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<string, symbol>();
let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
const taskEventRevisions = new Map<string, number>();
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<string, DesignMessage>();
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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((set, get) =>
activeConversationId: resolvedConversation.conversationId,
conversation: resolvedConversation,
pendingTurn,
loadingOlderMessages: false,
olderMessagesError: null,
error: null,
};
});
@@ -711,9 +734,6 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((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<ImageWorkspaceState>((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<ImageWorkspaceState>((set, get) =>
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
@@ -861,6 +891,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((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<ImageWorkspaceState>((set, get) =>
creatingConversation: false,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
@@ -990,6 +1024,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeConversationId: conversationId,
conversation: null,
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});
@@ -1047,6 +1083,35 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((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<ImageWorkspaceState>((set, get) =>
generationQuoteUpdateSequence += 1;
reconcileTaskStateOnNextConnect = false;
stopTaskStream();
pendingTaskEventConnections.clear();
set({
status: 'idle',
bootstrap: null,
@@ -1317,6 +1383,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
deletingWorkspaceId: null,
tasks: [],
pendingTurn: null,
loadingOlderMessages: false,
olderMessagesError: null,
taskStreamState: 'idle',
error: null,
});

View File

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

View File

@@ -51,6 +51,14 @@ function createRequest(method: string, body?: unknown): IncomingMessage {
return request;
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<never>;
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);
});
});

View File

@@ -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<EventSource>();
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<DesignGenerationTask[]>();
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);

View File

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

View File

@@ -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<typeof fetch>().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<typeof fetch>(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,