refactor: stabilize live chat sync materialization (#1132)
Canonicalize session message/part materialization across load, prefetch, reconnect, and recovery paths so OpenChamber restores session snapshots through one consistent merge flow. Preserve live assistant streaming text when stale or delayed snapshots arrive, while still replacing optimistic user parts with confirmed server snapshots to avoid duplicated user messages. Narrow recovery triggers to explicit incomplete snapshot signals instead of broad session-event fallbacks, reducing unnecessary session refetches during active streaming. Keep turn windowing aligned with parented assistant replies and add regression coverage for materialization gaps, stale snapshot protection, optimistic user replacement, reconnect recovery, and turn grouping.
This commit is contained in:
committed by
GitHub
parent
ff830d3812
commit
e892346c6b
@@ -39,6 +39,7 @@ import {
|
||||
useSessionStatus,
|
||||
} from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -329,8 +330,8 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
// Sync actions
|
||||
const sync = useSync();
|
||||
const loadMessages = React.useCallback(
|
||||
(sessionId: string) => sync.syncSession(sessionId),
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
||||
[sync],
|
||||
);
|
||||
const loadMoreMessages = React.useCallback(
|
||||
@@ -363,9 +364,9 @@ export const ChatContainer: React.FC = () => {
|
||||
),
|
||||
);
|
||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
||||
const hasLoadedSessionMessages = useDirectorySync(
|
||||
const hasRenderableSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
@@ -425,10 +426,6 @@ export const ChatContainer: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (streamingMessageId || activeStreamingPhase) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
return true;
|
||||
@@ -440,7 +437,7 @@ export const ChatContainer: React.FC = () => {
|
||||
&& lastMessage.role === 'assistant'
|
||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
||||
);
|
||||
}, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type, streamingMessageId]);
|
||||
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type]);
|
||||
const activeRetryStatus = React.useMemo(() => {
|
||||
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
||||
return null;
|
||||
@@ -751,7 +748,7 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasLoadedSessionMessages;
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) {
|
||||
@@ -783,10 +780,10 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (hasLoadedSessionMessages) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
|
||||
const load = async () => {
|
||||
await loadMessages(currentSessionId).finally(() => {
|
||||
await ensureSessionRenderable(currentSessionId).finally(() => {
|
||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
@@ -805,7 +802,7 @@ export const ChatContainer: React.FC = () => {
|
||||
};
|
||||
|
||||
void load();
|
||||
}, [currentSessionId, hasLoadedSessionMessages, isPinned, loadMessages, resumeToLatestInstant, sessionStatusForCurrent.type]);
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, isPinned, resumeToLatestInstant, sessionStatusForCurrent.type]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
return (
|
||||
@@ -841,7 +838,7 @@ export const ChatContainer: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
@@ -897,7 +894,7 @@ export const ChatContainer: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background transform-gpu">
|
||||
{returnToParentButton}
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
@@ -768,9 +769,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||
const hasCurrentSessionMessagesEntry = useDirectorySync(
|
||||
const hasRenderableCurrentSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
currentSessionDirectory ?? undefined,
|
||||
@@ -942,7 +943,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
|
||||
if (!contextHydrated || providers.length === 0 || !hasRenderableCurrentSessionSnapshot || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -990,7 +991,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentAgentName,
|
||||
contextHydrated,
|
||||
providers,
|
||||
hasCurrentSessionMessagesEntry,
|
||||
hasRenderableCurrentSessionSnapshot,
|
||||
latestLoadedUserChoice,
|
||||
setAgent,
|
||||
tryApplyModelSelection,
|
||||
@@ -1113,9 +1114,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasCurrentSessionMessagesEntry) {
|
||||
if (!hasRenderableCurrentSessionSnapshot) {
|
||||
if (!sync.isLoading(currentSessionId)) {
|
||||
void sync.syncSession(currentSessionId);
|
||||
void sync.ensureSessionRenderable(currentSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1127,7 +1128,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
applyFallbackAgent();
|
||||
}, [
|
||||
currentSessionId,
|
||||
hasCurrentSessionMessagesEntry,
|
||||
hasRenderableCurrentSessionSnapshot,
|
||||
latestLoadedUserChoice,
|
||||
agents,
|
||||
primaryAgents,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { buildTurnWindowModel, updateTurnWindowModelIncremental } from './windowTurns';
|
||||
import type { ChatMessageEntry } from './types';
|
||||
|
||||
function message({ id, role, parentID }: { id: string; role: 'user' | 'assistant' | 'system'; parentID?: string }): ChatMessageEntry {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
role,
|
||||
...(parentID ? { parentID } : {}),
|
||||
time: { created: 1 },
|
||||
} as Message,
|
||||
parts: [] as Part[],
|
||||
};
|
||||
}
|
||||
|
||||
describe('windowTurns', () => {
|
||||
test('does not map assistant messages without a parent to the current turn', () => {
|
||||
const user = message({ id: 'u1', role: 'user' });
|
||||
const assistant = message({ id: 'a1', role: 'assistant' });
|
||||
|
||||
const model = buildTurnWindowModel([user, assistant]);
|
||||
|
||||
expect(model.messageToTurnId.get('u1')).toBe('u1');
|
||||
expect(model.messageToTurnId.has('a1')).toBe(false);
|
||||
});
|
||||
|
||||
test('incremental update does not map assistant messages without a parent to the current turn', () => {
|
||||
const user = message({ id: 'u1', role: 'user' });
|
||||
const assistant = message({ id: 'a1', role: 'assistant' });
|
||||
const base = buildTurnWindowModel([user]);
|
||||
|
||||
const next = updateTurnWindowModelIncremental(base, [user], [user, assistant]);
|
||||
|
||||
expect(next?.messageToTurnId.get('u1')).toBe('u1');
|
||||
expect(next?.messageToTurnId.has('a1')).toBe(false);
|
||||
});
|
||||
|
||||
test('maps assistant messages to their parent user turn', () => {
|
||||
const user = message({ id: 'u1', role: 'user' });
|
||||
const assistant = message({ id: 'a1', role: 'assistant', parentID: 'u1' });
|
||||
|
||||
const model = buildTurnWindowModel([user, assistant]);
|
||||
|
||||
expect(model.messageToTurnId.get('a1')).toBe('u1');
|
||||
});
|
||||
});
|
||||
@@ -119,9 +119,10 @@ export const updateTurnWindowModelIncremental = (
|
||||
}
|
||||
|
||||
const parentId = resolveParentMessageId(nextMessage);
|
||||
const targetTurnIndex = parentId
|
||||
? nextModel.turnIndexById.get(parentId)
|
||||
: nextModel.turnIds.length - 1;
|
||||
if (!parentId) {
|
||||
return nextModel;
|
||||
}
|
||||
const targetTurnIndex = nextModel.turnIndexById.get(parentId);
|
||||
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -173,8 +174,13 @@ export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowMo
|
||||
}
|
||||
|
||||
const parentId = resolveParentMessageId(message);
|
||||
const parentTurnIndex = parentId ? userMessageToTurnIndex.get(parentId) : undefined;
|
||||
const targetTurnIndex = typeof parentTurnIndex === 'number' ? parentTurnIndex : currentTurnIndex;
|
||||
if (!parentId) {
|
||||
return;
|
||||
}
|
||||
const targetTurnIndex = userMessageToTurnIndex.get(parentId);
|
||||
if (typeof targetTurnIndex !== 'number') {
|
||||
return;
|
||||
}
|
||||
if (targetTurnIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1087,7 +1087,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
currentSessionId,
|
||||
sortedSessions,
|
||||
recentSessionIds: recentSessionIdsList,
|
||||
loadMessages: sync.syncSession,
|
||||
ensureSessionRenderable: sync.ensureSessionRenderable,
|
||||
});
|
||||
|
||||
const sectionsForSidebarRender = React.useMemo(() => {
|
||||
|
||||
@@ -347,7 +347,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
let skipped = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
await sync.syncSession(child.session.id);
|
||||
await sync.ensureSessionRenderable(child.session.id);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
@@ -379,7 +379,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
await sync.syncSession(session.id);
|
||||
await sync.ensureSessionRenderable(session.id);
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getSyncMessages } from '@/sync/sync-refs';
|
||||
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
|
||||
|
||||
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||
const SESSION_PREFETCH_SETTLE_MS = 600;
|
||||
@@ -12,10 +12,10 @@ type Args = {
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessionIds?: string[];
|
||||
loadMessages: (sessionId: string) => Promise<unknown>;
|
||||
ensureSessionRenderable: (sessionId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], ensureSessionRenderable }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -36,30 +36,28 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if messages already loaded in sync child store
|
||||
const hasMessages = getSyncMessages(nextSessionId).length > 0;
|
||||
if (hasMessages) {
|
||||
// Check if the session is already renderable in the sync child store.
|
||||
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void loadMessages(nextSessionId)
|
||||
void ensureSessionRenderable(nextSessionId)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [loadMessages]);
|
||||
}, [ensureSessionRenderable]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already loaded in sync
|
||||
const hasMessages = getSyncMessages(sessionId).length > 0;
|
||||
if (hasMessages) {
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId).renderable) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user