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

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