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,
|
useSessionStatus,
|
||||||
} from '@/sync/sync-context';
|
} from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -329,8 +330,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
// Sync actions
|
// Sync actions
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
const loadMessages = React.useCallback(
|
const ensureSessionRenderable = React.useCallback(
|
||||||
(sessionId: string) => sync.syncSession(sessionId),
|
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
||||||
[sync],
|
[sync],
|
||||||
);
|
);
|
||||||
const loadMoreMessages = React.useCallback(
|
const loadMoreMessages = React.useCallback(
|
||||||
@@ -363,9 +364,9 @@ export const ChatContainer: React.FC = () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
||||||
const hasLoadedSessionMessages = useDirectorySync(
|
const hasRenderableSessionSnapshot = useDirectorySync(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
|
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||||
[currentSessionId],
|
[currentSessionId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -425,10 +426,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamingMessageId || activeStreamingPhase) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||||
if (statusType === 'busy' || statusType === 'retry') {
|
if (statusType === 'busy' || statusType === 'retry') {
|
||||||
return true;
|
return true;
|
||||||
@@ -440,7 +437,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
&& lastMessage.role === 'assistant'
|
&& lastMessage.role === 'assistant'
|
||||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
&& 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(() => {
|
const activeRetryStatus = React.useMemo(() => {
|
||||||
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
||||||
return null;
|
return null;
|
||||||
@@ -751,7 +748,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
const isSessionHydrating =
|
const isSessionHydrating =
|
||||||
Boolean(currentSessionId)
|
Boolean(currentSessionId)
|
||||||
&& !hasLoadedSessionMessages;
|
&& !hasRenderableSessionSnapshot;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
@@ -783,10 +780,10 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) return;
|
if (!currentSessionId) return;
|
||||||
if (hasLoadedSessionMessages) return;
|
if (hasRenderableSessionSnapshot) return;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await loadMessages(currentSessionId).finally(() => {
|
await ensureSessionRenderable(currentSessionId).finally(() => {
|
||||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||||
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||||
@@ -805,7 +802,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [currentSessionId, hasLoadedSessionMessages, isPinned, loadMessages, resumeToLatestInstant, sessionStatusForCurrent.type]);
|
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, isPinned, resumeToLatestInstant, sessionStatusForCurrent.type]);
|
||||||
|
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -841,7 +838,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
|
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col h-full bg-background">
|
<div className="relative flex flex-col h-full bg-background">
|
||||||
{returnToParentButton}
|
{returnToParentButton}
|
||||||
@@ -897,7 +894,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col h-full bg-background transform-gpu">
|
<div className="relative flex flex-col h-full bg-background transform-gpu">
|
||||||
{returnToParentButton}
|
{returnToParentButton}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useModelLists } from '@/hooks/useModelLists';
|
import { useModelLists } from '@/hooks/useModelLists';
|
||||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||||
@@ -768,9 +769,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||||
const hasCurrentSessionMessagesEntry = useDirectorySync(
|
const hasRenderableCurrentSessionSnapshot = useDirectorySync(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
|
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||||
[currentSessionId],
|
[currentSessionId],
|
||||||
),
|
),
|
||||||
currentSessionDirectory ?? undefined,
|
currentSessionDirectory ?? undefined,
|
||||||
@@ -942,7 +943,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
|
if (!contextHydrated || providers.length === 0 || !hasRenderableCurrentSessionSnapshot || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,7 +991,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
currentAgentName,
|
currentAgentName,
|
||||||
contextHydrated,
|
contextHydrated,
|
||||||
providers,
|
providers,
|
||||||
hasCurrentSessionMessagesEntry,
|
hasRenderableCurrentSessionSnapshot,
|
||||||
latestLoadedUserChoice,
|
latestLoadedUserChoice,
|
||||||
setAgent,
|
setAgent,
|
||||||
tryApplyModelSelection,
|
tryApplyModelSelection,
|
||||||
@@ -1113,9 +1114,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCurrentSessionMessagesEntry) {
|
if (!hasRenderableCurrentSessionSnapshot) {
|
||||||
if (!sync.isLoading(currentSessionId)) {
|
if (!sync.isLoading(currentSessionId)) {
|
||||||
void sync.syncSession(currentSessionId);
|
void sync.ensureSessionRenderable(currentSessionId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1128,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
applyFallbackAgent();
|
applyFallbackAgent();
|
||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
hasCurrentSessionMessagesEntry,
|
hasRenderableCurrentSessionSnapshot,
|
||||||
latestLoadedUserChoice,
|
latestLoadedUserChoice,
|
||||||
agents,
|
agents,
|
||||||
primaryAgents,
|
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 parentId = resolveParentMessageId(nextMessage);
|
||||||
const targetTurnIndex = parentId
|
if (!parentId) {
|
||||||
? nextModel.turnIndexById.get(parentId)
|
return nextModel;
|
||||||
: nextModel.turnIds.length - 1;
|
}
|
||||||
|
const targetTurnIndex = nextModel.turnIndexById.get(parentId);
|
||||||
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
|
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -173,8 +174,13 @@ export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parentId = resolveParentMessageId(message);
|
const parentId = resolveParentMessageId(message);
|
||||||
const parentTurnIndex = parentId ? userMessageToTurnIndex.get(parentId) : undefined;
|
if (!parentId) {
|
||||||
const targetTurnIndex = typeof parentTurnIndex === 'number' ? parentTurnIndex : currentTurnIndex;
|
return;
|
||||||
|
}
|
||||||
|
const targetTurnIndex = userMessageToTurnIndex.get(parentId);
|
||||||
|
if (typeof targetTurnIndex !== 'number') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (targetTurnIndex < 0) {
|
if (targetTurnIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1087,7 +1087,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
sortedSessions,
|
sortedSessions,
|
||||||
recentSessionIds: recentSessionIdsList,
|
recentSessionIds: recentSessionIdsList,
|
||||||
loadMessages: sync.syncSession,
|
ensureSessionRenderable: sync.ensureSessionRenderable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionsForSidebarRender = React.useMemo(() => {
|
const sectionsForSidebarRender = React.useMemo(() => {
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
try {
|
try {
|
||||||
await sync.syncSession(child.session.id);
|
await sync.ensureSessionRenderable(child.session.id);
|
||||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||||
@@ -379,7 +379,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sync.syncSession(session.id);
|
await sync.ensureSessionRenderable(session.id);
|
||||||
|
|
||||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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_HOVER_DELAY_MS = 180;
|
||||||
const SESSION_PREFETCH_SETTLE_MS = 600;
|
const SESSION_PREFETCH_SETTLE_MS = 600;
|
||||||
@@ -12,10 +12,10 @@ type Args = {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
sortedSessions: Session[];
|
sortedSessions: Session[];
|
||||||
recentSessionIds?: string[];
|
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 sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||||
@@ -36,30 +36,28 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if messages already loaded in sync child store
|
// Check if the session is already renderable in the sync child store.
|
||||||
const hasMessages = getSyncMessages(nextSessionId).length > 0;
|
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) {
|
||||||
if (hasMessages) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||||
void loadMessages(nextSessionId)
|
void ensureSessionRenderable(nextSessionId)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||||
pumpSessionPrefetchQueue();
|
pumpSessionPrefetchQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [loadMessages]);
|
}, [ensureSessionRenderable]);
|
||||||
|
|
||||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||||
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already loaded in sync
|
// Already renderable in sync
|
||||||
const hasMessages = getSyncMessages(sessionId).length > 0;
|
if (getSyncSessionMaterializationStatus(sessionId).renderable) {
|
||||||
if (hasMessages) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Event, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { applyDirectoryEvent } from "../event-reducer"
|
||||||
|
import { INITIAL_STATE, type State } from "../types"
|
||||||
|
|
||||||
|
function state(overrides: Partial<State> = {}): State {
|
||||||
|
return {
|
||||||
|
...INITIAL_STATE,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaEvent(): Event {
|
||||||
|
return {
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
messageID: "msg_1",
|
||||||
|
partID: "prt_1",
|
||||||
|
field: "text",
|
||||||
|
delta: "hello",
|
||||||
|
},
|
||||||
|
} as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
function partUpdatedEvent(): Event {
|
||||||
|
return {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_1",
|
||||||
|
messageID: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyDirectoryEvent", () => {
|
||||||
|
test("returns typed materialization when delta arrives before parts", () => {
|
||||||
|
const result = applyDirectoryEvent(state(), deltaEvent())
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
changed: false,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns typed materialization when delta part is missing", () => {
|
||||||
|
const result = applyDirectoryEvent(
|
||||||
|
state({ part: { msg_1: [{ id: "prt_2", messageID: "msg_1", type: "text", text: "" } as Part] } }),
|
||||||
|
deltaEvent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
changed: false,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies part update and requests materialization when owning message is absent", () => {
|
||||||
|
const draft = state()
|
||||||
|
const result = applyDirectoryEvent(draft, partUpdatedEvent())
|
||||||
|
|
||||||
|
expect(draft.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
|
||||||
|
expect(result).toEqual({
|
||||||
|
changed: true,
|
||||||
|
materialization: {
|
||||||
|
type: "incomplete-session-snapshot",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
messageID: "msg_1",
|
||||||
|
partID: "prt_1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies part update without materialization when owning message exists", () => {
|
||||||
|
const draft = state({
|
||||||
|
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as never] },
|
||||||
|
})
|
||||||
|
const result = applyDirectoryEvent(draft, partUpdatedEvent())
|
||||||
|
|
||||||
|
expect(draft.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "../materialization"
|
||||||
|
|
||||||
|
function message(id: string, sessionID = "ses_1"): Message {
|
||||||
|
return { id, sessionID, role: "assistant", time: { created: 1 } } as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(id: string, sessionID = "ses_1"): Message {
|
||||||
|
return { id, sessionID, role: "user", time: { created: 1 } } as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
function part(id: string, messageID: string, type = "text", text = id): Part {
|
||||||
|
return { id, messageID, sessionID: "ses_1", type, text } as Part
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("materializeSessionSnapshots", () => {
|
||||||
|
test("materializes messages and parts together", () => {
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
{ message: {}, part: {} },
|
||||||
|
"ses_1",
|
||||||
|
[{ info: message("msg_1"), parts: [part("prt_1", "msg_1")] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.message.ses_1.map((item) => item.id)).toEqual(["msg_1"])
|
||||||
|
expect(result.part.msg_1.map((item) => item.id)).toEqual(["prt_1"])
|
||||||
|
expect(result.messagesChanged).toBe(true)
|
||||||
|
expect(result.partsChanged).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves unchanged references", () => {
|
||||||
|
const existingMessage = message("msg_1")
|
||||||
|
const existingPart = part("prt_1", "msg_1")
|
||||||
|
const state = { message: { ses_1: [existingMessage] }, part: { msg_1: [existingPart] } }
|
||||||
|
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
state,
|
||||||
|
"ses_1",
|
||||||
|
[{ info: existingMessage, parts: [existingPart] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.message).toBe(state.message)
|
||||||
|
expect(result.part).toBe(state.part)
|
||||||
|
expect(result.messagesChanged).toBe(false)
|
||||||
|
expect(result.partsChanged).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips non-rendered part types", () => {
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
{ message: {}, part: {} },
|
||||||
|
"ses_1",
|
||||||
|
[{ info: message("msg_1"), parts: [part("prt_patch", "msg_1", "patch"), part("prt_text", "msg_1")] }],
|
||||||
|
{ skipPartTypes: new Set(["patch"]) },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.part.msg_1.map((item) => item.id)).toEqual(["prt_text"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves newer live streaming text when a stale snapshot materializes", () => {
|
||||||
|
const livePart = part("prt_1", "msg_1", "text", "First chunk ")
|
||||||
|
const stalePart = part("prt_1", "msg_1", "text", "")
|
||||||
|
const state = {
|
||||||
|
message: { ses_1: [message("msg_1")] },
|
||||||
|
part: { msg_1: [livePart] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
state,
|
||||||
|
"ses_1",
|
||||||
|
[{ info: message("msg_1"), parts: [stalePart] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.part.msg_1[0]).toBe(livePart)
|
||||||
|
expect((result.part.msg_1[0] as { text?: string })?.text).toBe("First chunk ")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves live streaming parts omitted by a stale snapshot", () => {
|
||||||
|
const livePart = part("prt_1", "msg_1", "text", "First chunk ")
|
||||||
|
const state = {
|
||||||
|
message: { ses_1: [message("msg_1")] },
|
||||||
|
part: { msg_1: [livePart] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
state,
|
||||||
|
"ses_1",
|
||||||
|
[{ info: message("msg_1"), parts: [] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.part.msg_1[0]).toBe(livePart)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
|
||||||
|
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
|
||||||
|
const serverPart = part("prt_server", "msg_1", "text", "Hello")
|
||||||
|
const state = {
|
||||||
|
message: { ses_1: [userMessage("msg_1")] },
|
||||||
|
part: { msg_1: [optimisticPart] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
state,
|
||||||
|
"ses_1",
|
||||||
|
[{ info: userMessage("msg_1"), parts: [serverPart] }],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.part.msg_1).toEqual([serverPart])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getSessionMaterializationStatus", () => {
|
||||||
|
test("requires assistant parts for renderable cached state", () => {
|
||||||
|
const state = {
|
||||||
|
message: { ses_1: [message("msg_1")] },
|
||||||
|
part: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getSessionMaterializationStatus(state, "ses_1")).toEqual({
|
||||||
|
hasMessages: true,
|
||||||
|
renderable: false,
|
||||||
|
missingPartMessageIDs: ["msg_1"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("treats user-only cached state as renderable", () => {
|
||||||
|
const state = {
|
||||||
|
message: { ses_1: [{ ...message("msg_1"), role: "user" } as Message] },
|
||||||
|
part: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getSessionMaterializationStatus(state, "ses_1")).toEqual({
|
||||||
|
hasMessages: true,
|
||||||
|
renderable: true,
|
||||||
|
missingPartMessageIDs: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -136,7 +136,15 @@ function toWebSocketUrl(candidate: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildGlobalEventWsUrl(lastEventId?: string): string {
|
function buildGlobalEventWsUrl(lastEventId?: string): string {
|
||||||
const baseUrl = opencodeClient.getBaseUrl()
|
let baseUrl = "/api"
|
||||||
|
try {
|
||||||
|
const client = opencodeClient as { getBaseUrl?: () => string }
|
||||||
|
if (typeof client.getBaseUrl === "function") {
|
||||||
|
baseUrl = client.getBaseUrl()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
baseUrl = "/api"
|
||||||
|
}
|
||||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
|
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
|
||||||
const httpUrl = new URL("global/event/ws", resolveAbsoluteUrl(normalizedBase))
|
const httpUrl = new URL("global/event/ws", resolveAbsoluteUrl(normalizedBase))
|
||||||
if (lastEventId && lastEventId.length > 0) {
|
if (lastEventId && lastEventId.length > 0) {
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ export type GlobalEventResult = {
|
|||||||
project: Project
|
project: Project
|
||||||
} | null
|
} | null
|
||||||
|
|
||||||
|
export type DirectoryEventResult = boolean | {
|
||||||
|
changed: boolean
|
||||||
|
materialization: {
|
||||||
|
type: "incomplete-session-snapshot"
|
||||||
|
sessionID?: string
|
||||||
|
messageID: string
|
||||||
|
partID?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMessage(draft: State, sessionID: string | undefined, messageID: string): boolean {
|
||||||
|
if (!sessionID) return false
|
||||||
|
const messages = draft.message[sessionID]
|
||||||
|
if (!messages) return false
|
||||||
|
return Binary.search(messages, messageID, (message) => message.id).found
|
||||||
|
}
|
||||||
|
|
||||||
export function reduceGlobalEvent(event: Event): GlobalEventResult {
|
export function reduceGlobalEvent(event: Event): GlobalEventResult {
|
||||||
if (event.type === "global.disposed" || event.type === "server.connected") {
|
if (event.type === "global.disposed" || event.type === "server.connected") {
|
||||||
return { type: "refresh" }
|
return { type: "refresh" }
|
||||||
@@ -135,7 +152,7 @@ export function applyDirectoryEvent(
|
|||||||
onLoadLsp?: () => void
|
onLoadLsp?: () => void
|
||||||
onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
},
|
},
|
||||||
): boolean {
|
): DirectoryEventResult {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed": {
|
case "server.instance.disposed": {
|
||||||
callbacks?.onRefresh?.("")
|
callbacks?.onRefresh?.("")
|
||||||
@@ -269,11 +286,18 @@ export function applyDirectoryEvent(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const messageID = (part as { messageID: string }).messageID
|
const messageID = (part as { messageID: string }).messageID
|
||||||
|
const sessionID = (part as { sessionID?: string }).sessionID
|
||||||
|
const missingOwningMessage = !hasMessage(draft, sessionID, messageID)
|
||||||
const parts = draft.part[messageID]
|
const parts = draft.part[messageID]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type)
|
syncDebug.reducer.partUpdatedNoExistingParts(messageID, part.id, part.type)
|
||||||
draft.part[messageID] = [part]
|
draft.part[messageID] = [part]
|
||||||
return true
|
return missingOwningMessage
|
||||||
|
? {
|
||||||
|
changed: true,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id },
|
||||||
|
}
|
||||||
|
: true
|
||||||
}
|
}
|
||||||
const next = [...parts]
|
const next = [...parts]
|
||||||
const result = Binary.search(next, part.id, (p) => p.id)
|
const result = Binary.search(next, part.id, (p) => p.id)
|
||||||
@@ -302,7 +326,12 @@ export function applyDirectoryEvent(
|
|||||||
next.splice(insertResult.index, 0, part)
|
next.splice(insertResult.index, 0, part)
|
||||||
}
|
}
|
||||||
draft.part[messageID] = next
|
draft.part[messageID] = next
|
||||||
return true
|
return missingOwningMessage
|
||||||
|
? {
|
||||||
|
changed: true,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id },
|
||||||
|
}
|
||||||
|
: true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
@@ -333,12 +362,18 @@ export function applyDirectoryEvent(
|
|||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID)
|
syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID)
|
||||||
return false
|
return {
|
||||||
|
changed: false,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", messageID: props.messageID, partID: props.partID },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||||
if (!result.found) {
|
if (!result.found) {
|
||||||
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
||||||
return false
|
return {
|
||||||
|
changed: false,
|
||||||
|
materialization: { type: "incomplete-session-snapshot", messageID: props.messageID, partID: props.partID },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const existing = parts[result.index] as Record<string, unknown>
|
const existing = parts[result.index] as Record<string, unknown>
|
||||||
const existingValue = existing[props.field] as string | undefined
|
const existingValue = existing[props.field] as string | undefined
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { mergeMessages } from "./optimistic"
|
||||||
|
|
||||||
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
const STREAMING_PART_FIELDS = ["text", "output"] as const
|
||||||
|
|
||||||
|
export type MaterializedMessageRecord = {
|
||||||
|
info: Message
|
||||||
|
parts: Part[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterializedState = {
|
||||||
|
message: Record<string, Message[]>
|
||||||
|
part: Record<string, Part[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterializeSessionSnapshotsOptions = {
|
||||||
|
skipPartTypes?: ReadonlySet<string>
|
||||||
|
mode?: "merge" | "prepend"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterializeSessionSnapshotsResult = {
|
||||||
|
message: Record<string, Message[]>
|
||||||
|
part: Record<string, Part[]>
|
||||||
|
messages: Message[]
|
||||||
|
messagesChanged: boolean
|
||||||
|
partsChanged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionMaterializationStatus = {
|
||||||
|
hasMessages: boolean
|
||||||
|
renderable: boolean
|
||||||
|
missingPartMessageIDs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
|
||||||
|
return parts
|
||||||
|
.filter((part) => !!part?.id && !skipPartTypes.has(part.type))
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
||||||
|
if (!left) return right.length === 0
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const leftPart = left[index]
|
||||||
|
const rightPart = right[index]
|
||||||
|
if (!leftPart || !rightPart) return false
|
||||||
|
if (leftPart.id !== rightPart.id) return false
|
||||||
|
if (JSON.stringify(leftPart) !== JSON.stringify(rightPart)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartEndTime(part: Part): number | undefined {
|
||||||
|
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end
|
||||||
|
if (typeof stateEnd === "number") {
|
||||||
|
return stateEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeEnd = (part as { time?: { end?: unknown } }).time?.end
|
||||||
|
return typeof timeEnd === "number" ? timeEnd : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringField(part: Part, field: "text" | "output"): string | undefined {
|
||||||
|
const value = (part as Record<string, unknown>)[field]
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLiveStreamingField(part: Part): boolean {
|
||||||
|
if (getPartEndTime(part) !== undefined) return false
|
||||||
|
return STREAMING_PART_FIELDS.some((field) => {
|
||||||
|
const value = getStringField(part, field)
|
||||||
|
return typeof value === "string" && value.length > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMaterializedPart(existing: Part | undefined, next: Part): Part {
|
||||||
|
if (!existing || getPartEndTime(next) !== undefined) return next
|
||||||
|
|
||||||
|
let merged: Part = next
|
||||||
|
for (const field of STREAMING_PART_FIELDS) {
|
||||||
|
const existingValue = getStringField(existing, field)
|
||||||
|
if (!existingValue) continue
|
||||||
|
|
||||||
|
const nextValue = getStringField(next, field)
|
||||||
|
if (typeof nextValue === "string" && nextValue.length >= existingValue.length) continue
|
||||||
|
if (typeof nextValue === "string" && nextValue.length > 0 && !existingValue.startsWith(nextValue)) continue
|
||||||
|
|
||||||
|
if (merged === next) merged = { ...next }
|
||||||
|
const mergedRecord = merged as Record<string, unknown>
|
||||||
|
mergedRecord[field] = existingValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMaterializedParts(
|
||||||
|
existing: Part[] | undefined,
|
||||||
|
nextParts: Part[],
|
||||||
|
skipPartTypes: ReadonlySet<string>,
|
||||||
|
preserveLiveStreamingParts: boolean,
|
||||||
|
): Part[] {
|
||||||
|
if (!existing || existing.length === 0) return nextParts
|
||||||
|
if (!preserveLiveStreamingParts) return nextParts
|
||||||
|
|
||||||
|
const existingByID = new Map(existing.map((part) => [part.id, part]))
|
||||||
|
let mergedParts = nextParts
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
for (let index = 0; index < nextParts.length; index += 1) {
|
||||||
|
const nextPart = nextParts[index]
|
||||||
|
const mergedPart = mergeMaterializedPart(existingByID.get(nextPart.id), nextPart)
|
||||||
|
if (mergedPart === nextPart) continue
|
||||||
|
if (!changed) mergedParts = [...nextParts]
|
||||||
|
mergedParts[index] = mergedPart
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotIDs = new Set(nextParts.map((part) => part.id))
|
||||||
|
const missingLiveParts = existing.filter(
|
||||||
|
(part) => !!part?.id && !snapshotIDs.has(part.id) && !skipPartTypes.has(part.type) && hasLiveStreamingField(part),
|
||||||
|
)
|
||||||
|
if (missingLiveParts.length === 0) return mergedParts
|
||||||
|
|
||||||
|
return [...mergedParts, ...missingLiveParts].sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeSessionSnapshots(
|
||||||
|
state: MaterializedState,
|
||||||
|
sessionID: string,
|
||||||
|
records: MaterializedMessageRecord[],
|
||||||
|
options: MaterializeSessionSnapshotsOptions = {},
|
||||||
|
): MaterializeSessionSnapshotsResult {
|
||||||
|
const skipPartTypes = options.skipPartTypes ?? new Set<string>()
|
||||||
|
const snapshots = records
|
||||||
|
.filter((record) => !!record?.info?.id)
|
||||||
|
.sort((left, right) => cmp(left.info.id, right.info.id))
|
||||||
|
const nextMessages = snapshots.map((record) => record.info)
|
||||||
|
const currentMessages = state.message[sessionID] ?? []
|
||||||
|
const messages = mergeMessages(currentMessages, nextMessages)
|
||||||
|
const messagesChanged = messages !== currentMessages
|
||||||
|
|
||||||
|
let partsChanged = false
|
||||||
|
const nextPartState = { ...state.part }
|
||||||
|
const isPrepend = options.mode === "prepend"
|
||||||
|
|
||||||
|
for (const record of snapshots) {
|
||||||
|
const messageID = record.info.id
|
||||||
|
if (isPrepend && nextPartState[messageID]) continue
|
||||||
|
|
||||||
|
const existing = nextPartState[messageID]
|
||||||
|
const nextParts = mergeMaterializedParts(
|
||||||
|
existing,
|
||||||
|
sortParts(record.parts ?? [], skipPartTypes),
|
||||||
|
skipPartTypes,
|
||||||
|
record.info.role === "assistant",
|
||||||
|
)
|
||||||
|
if (haveEquivalentPartSnapshots(existing, nextParts)) continue
|
||||||
|
|
||||||
|
if (nextParts.length === 0) {
|
||||||
|
delete nextPartState[messageID]
|
||||||
|
} else {
|
||||||
|
nextPartState[messageID] = nextParts
|
||||||
|
}
|
||||||
|
partsChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: messagesChanged ? { ...state.message, [sessionID]: messages } : state.message,
|
||||||
|
part: partsChanged ? nextPartState : state.part,
|
||||||
|
messages,
|
||||||
|
messagesChanged,
|
||||||
|
partsChanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionMaterializationStatus(
|
||||||
|
state: MaterializedState,
|
||||||
|
sessionID: string,
|
||||||
|
): SessionMaterializationStatus {
|
||||||
|
const messages = state.message[sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
return { hasMessages: false, renderable: false, missingPartMessageIDs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingPartMessageIDs: string[] = []
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role !== "assistant") continue
|
||||||
|
const parts = state.part[message.id]
|
||||||
|
if (!parts || parts.length === 0) {
|
||||||
|
missingPartMessageIDs.push(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasMessages: true,
|
||||||
|
renderable: missingPartMessageIDs.length === 0,
|
||||||
|
missingPartMessageIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
||||||
|
|
||||||
@@ -23,6 +23,10 @@ function createAssistantMessage(id: string, sessionID: string, completed?: numbe
|
|||||||
} as unknown as Message
|
} as unknown as Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPart(id: string, messageID: string): Part {
|
||||||
|
return { id, messageID, sessionID: "active", type: "text", text: "done" } as Part
|
||||||
|
}
|
||||||
|
|
||||||
describe("getReconnectCandidateSessionIds", () => {
|
describe("getReconnectCandidateSessionIds", () => {
|
||||||
test("includes non-idle, incomplete assistant, and parent sessions", () => {
|
test("includes non-idle, incomplete assistant, and parent sessions", () => {
|
||||||
const busyStatus = { type: "busy" } as SessionStatus
|
const busyStatus = { type: "busy" } as SessionStatus
|
||||||
@@ -48,6 +52,9 @@ describe("getReconnectCandidateSessionIds", () => {
|
|||||||
message: {
|
message: {
|
||||||
active: [createAssistantMessage("m-1", "active", 1)],
|
active: [createAssistantMessage("m-1", "active", 1)],
|
||||||
},
|
},
|
||||||
|
part: {
|
||||||
|
"m-1": [createPart("p-1", "m-1")],
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
viewedSession: { directory: "/repo", sessionId: "active" },
|
viewedSession: { directory: "/repo", sessionId: "active" },
|
||||||
@@ -72,6 +79,9 @@ describe("getReconnectCandidateSessionIds", () => {
|
|||||||
message: {
|
message: {
|
||||||
active: [createAssistantMessage("m-1", "active", 1)],
|
active: [createAssistantMessage("m-1", "active", 1)],
|
||||||
},
|
},
|
||||||
|
part: {
|
||||||
|
"m-1": [createPart("p-1", "m-1")],
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
directory: "/repo-a",
|
directory: "/repo-a",
|
||||||
viewedSession: { directory: "/repo-b", sessionId: "active" },
|
viewedSession: { directory: "/repo-b", sessionId: "active" },
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import type { SessionStatus, Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { SessionStatus, Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { getSessionMaterializationStatus } from "./materialization"
|
||||||
|
|
||||||
type ReconnectRecoveryState = {
|
type ReconnectMaterializationState = {
|
||||||
session: Session[]
|
session: Session[]
|
||||||
session_status?: Record<string, SessionStatus>
|
session_status?: Record<string, SessionStatus>
|
||||||
message?: Record<string, Message[]>
|
message?: Record<string, Message[]>
|
||||||
part?: Record<string, Part[]>
|
part?: Record<string, Part[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewedSessionRecoveryTarget = {
|
export type ViewedSessionMaterializationTarget = {
|
||||||
directory: string
|
directory: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReconnectCandidateOptions = {
|
type ReconnectCandidateOptions = {
|
||||||
directory?: string
|
directory?: string
|
||||||
viewedSession?: ViewedSessionRecoveryTarget | null
|
viewedSession?: ViewedSessionMaterializationTarget | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReconnectCandidateSessionIds(state: ReconnectRecoveryState, options?: ReconnectCandidateOptions) {
|
export function getReconnectCandidateSessionIds(state: ReconnectMaterializationState, options?: ReconnectCandidateOptions) {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>()
|
||||||
|
|
||||||
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
|
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
|
||||||
@@ -27,16 +28,13 @@ export function getReconnectCandidateSessionIds(state: ReconnectRecoveryState, o
|
|||||||
|
|
||||||
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
|
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
const lastAssistantComplete = lastMessage
|
|
||||||
&& lastMessage.role === "assistant"
|
|
||||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed === "number"
|
|
||||||
if (
|
if (
|
||||||
lastMessage
|
lastMessage
|
||||||
&& lastMessage.role === "assistant"
|
&& lastMessage.role === "assistant"
|
||||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
||||||
) {
|
) {
|
||||||
ids.add(sessionId)
|
ids.add(sessionId)
|
||||||
} else if (lastAssistantComplete && state.part && (state.part[lastMessage.id]?.length ?? 0) === 0) {
|
} else if (!getSessionMaterializationStatus({ message: state.message ?? {}, part: state.part ?? {} }, sessionId).renderable) {
|
||||||
ids.add(sessionId)
|
ids.add(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
|||||||
import type { PermissionRequest } from "@/types/permission"
|
import type { PermissionRequest } from "@/types/permission"
|
||||||
import type { QuestionRequest } from "@/types/question"
|
import type { QuestionRequest } from "@/types/question"
|
||||||
import * as sessionActions from "./session-actions"
|
import * as sessionActions from "./session-actions"
|
||||||
import { mergeMessages } from "./optimistic"
|
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Context
|
// Context
|
||||||
@@ -123,7 +123,7 @@ let bootingRoot = false
|
|||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
const BOOT_DEBOUNCE_MS = 1500
|
const BOOT_DEBOUNCE_MS = 1500
|
||||||
const RECONNECT_MESSAGE_LIMIT = 30
|
const RECONNECT_MESSAGE_LIMIT = 30
|
||||||
const REPAIR_MESSAGE_LIMIT = 30
|
const SESSION_MATERIALIZATION_MESSAGE_LIMIT = 30
|
||||||
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||||
if (!items || items.length === 0) return ""
|
if (!items || items.length === 0) return ""
|
||||||
@@ -135,117 +135,77 @@ const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
|||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
const partRepairSignature = (part: Part): string => JSON.stringify(part)
|
|
||||||
const syncSnapshotSignature = (value: unknown): string => JSON.stringify(value)
|
const syncSnapshotSignature = (value: unknown): string => JSON.stringify(value)
|
||||||
|
|
||||||
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
|
||||||
if (!left) {
|
|
||||||
return right.length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < left.length; index += 1) {
|
|
||||||
const leftPart = left[index]
|
|
||||||
const rightPart = right[index]
|
|
||||||
if (!leftPart || !rightPart) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (leftPart.id !== rightPart.id) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (partRepairSignature(leftPart) !== partRepairSignature(rightPart)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean {
|
function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean {
|
||||||
return syncSnapshotSignature(left) === syncSnapshotSignature(right)
|
return syncSnapshotSignature(left) === syncSnapshotSignature(right)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parts-gap recovery — when SSE events arrive but parts are missing,
|
// Session materialization scheduler — when local message/part state is incomplete,
|
||||||
// trigger a targeted re-fetch for the affected sessions.
|
// fetch the canonical session snapshot and materialize messages and parts together.
|
||||||
// Tracked per-directory, deduplicated, and auto-expiring.
|
// Tracked per-directory, deduplicated, and auto-expiring.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type PendingRepair = {
|
type PendingSessionMaterialization = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
directory: string
|
directory: string
|
||||||
enqueuedAt: number
|
enqueuedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPAIR_COOLDOWN_MS = 5_000
|
const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000
|
||||||
const pendingRepairs = new Map<string, PendingRepair>() // key: directory:sessionID
|
const pendingSessionMaterializations = new Map<string, PendingSessionMaterialization>() // key: directory:sessionID
|
||||||
|
|
||||||
const repairKey = (directory: string, sessionID: string) => `${directory}:${sessionID}`
|
const materializationKey = (directory: string, sessionID: string) => `${directory}:${sessionID}`
|
||||||
|
|
||||||
function enqueuePartsRepair(directory: string, sessionID: string, childStores: ChildStoreManager) {
|
function enqueueSessionMaterialization(directory: string, sessionID: string, childStores: ChildStoreManager) {
|
||||||
if (!directory || directory === "global" || !sessionID) return
|
if (!directory || directory === "global" || !sessionID) return
|
||||||
const k = repairKey(directory, sessionID)
|
const k = materializationKey(directory, sessionID)
|
||||||
const existing = pendingRepairs.get(k)
|
const existing = pendingSessionMaterializations.get(k)
|
||||||
if (existing && Date.now() - existing.enqueuedAt < REPAIR_COOLDOWN_MS) return
|
if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return
|
||||||
|
|
||||||
pendingRepairs.set(k, { sessionID, directory, enqueuedAt: Date.now() })
|
pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now() })
|
||||||
|
|
||||||
// Defer to next microtask so we don't hold up the current event batch
|
// Defer to next microtask so we don't hold up the current event batch
|
||||||
void Promise.resolve().then(async () => {
|
void Promise.resolve().then(async () => {
|
||||||
const store = childStores.getChild(directory)
|
const store = childStores.getChild(directory)
|
||||||
if (!store) {
|
if (!store) {
|
||||||
pendingRepairs.delete(k)
|
pendingSessionMaterializations.delete(k)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await repairSessionParts(directory, sessionID, store)
|
await materializeSessionFromServer(directory, sessionID, store)
|
||||||
} catch {
|
} catch {
|
||||||
// Transient failure — next SSE event or reconnect will catch up.
|
// Transient failure — next SSE event or reconnect will catch up.
|
||||||
} finally {
|
} finally {
|
||||||
pendingRepairs.delete(k)
|
pendingSessionMaterializations.delete(k)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function repairSessionParts(
|
async function materializeSessionFromServer(
|
||||||
directory: string,
|
directory: string,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
store: StoreApi<DirectoryStore>,
|
store: StoreApi<DirectoryStore>,
|
||||||
) {
|
) {
|
||||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||||
const result = await retry(() =>
|
const result = await retry(() =>
|
||||||
scopedClient.session.messages({ sessionID, limit: REPAIR_MESSAGE_LIMIT }),
|
scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }),
|
||||||
)
|
)
|
||||||
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||||
if (records.length === 0) return
|
if (records.length === 0) return
|
||||||
|
|
||||||
store.setState((state: DirectoryStore) => {
|
store.setState((state: DirectoryStore) => {
|
||||||
const nextMessages = records
|
const materialized = materializeSessionSnapshots(
|
||||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
state,
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
sessionID,
|
||||||
const currentMessages = state.message[sessionID] ?? []
|
records.map((record: { info: Message; parts?: Part[] }) => ({
|
||||||
const mergedMessages = mergeMessages(currentMessages, nextMessages)
|
info: stripMessageDiffSnapshots(record.info),
|
||||||
const nextPartState = { ...state.part }
|
parts: record.parts ?? [],
|
||||||
for (const record of records) {
|
})),
|
||||||
const messageId = record?.info?.id
|
{ skipPartTypes: RECONNECT_SKIP_PARTS },
|
||||||
if (!messageId) continue
|
)
|
||||||
const newParts = (record.parts ?? [])
|
return { message: materialized.message, part: materialized.part }
|
||||||
.filter((part: Part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
|
|
||||||
.sort((a: Part, b: Part) => cmp(a.id, b.id))
|
|
||||||
|
|
||||||
const existing = nextPartState[messageId]
|
|
||||||
// Repair when parts are missing, truncated, or stale-but-same-length.
|
|
||||||
if (!haveEquivalentPartSnapshots(existing, newParts)) {
|
|
||||||
nextPartState[messageId] = newParts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
message: mergedMessages !== currentMessages ? { ...state.message, [sessionID]: mergedMessages } : state.message,
|
|
||||||
part: nextPartState,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +249,7 @@ function isRecentBoot() {
|
|||||||
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
|
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
function getViewedSessionRecoveryTarget(directory: string) {
|
function getViewedSessionMaterializationTarget(directory: string) {
|
||||||
if (!_activeDirectory || !_activeSession) return null
|
if (!_activeDirectory || !_activeSession) return null
|
||||||
if (directory !== _activeDirectory) return null
|
if (directory !== _activeDirectory) return null
|
||||||
return {
|
return {
|
||||||
@@ -582,6 +542,29 @@ const findSessionInChildStores = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const childStoreHasSessionState = (
|
||||||
|
childStores: ChildStoreManager,
|
||||||
|
directory: string,
|
||||||
|
sessionID: string,
|
||||||
|
): boolean => {
|
||||||
|
const store = childStores.getChild(directory)
|
||||||
|
if (!store) return false
|
||||||
|
const state = store.getState()
|
||||||
|
return state.session.some((session) => session.id === sessionID)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.message, sessionID)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const childStoreHasMessagePartState = (
|
||||||
|
childStores: ChildStoreManager,
|
||||||
|
directory: string,
|
||||||
|
messageID: string,
|
||||||
|
): boolean => {
|
||||||
|
const store = childStores.getChild(directory)
|
||||||
|
if (!store) return false
|
||||||
|
return Object.prototype.hasOwnProperty.call(store.getState().part, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveDirectoryFromRoutingIndex = (
|
const resolveDirectoryFromRoutingIndex = (
|
||||||
routingIndex: EventRoutingIndex,
|
routingIndex: EventRoutingIndex,
|
||||||
rawDirectory: string,
|
rawDirectory: string,
|
||||||
@@ -592,8 +575,13 @@ const resolveDirectoryFromRoutingIndex = (
|
|||||||
|
|
||||||
const sessionID = getSessionIdFromPayload(payload)
|
const sessionID = getSessionIdFromPayload(payload)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
|
if (normalizedDirectory && normalizedDirectory !== "global" && childStoreHasSessionState(childStores, normalizedDirectory, sessionID)) {
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, normalizedDirectory)
|
||||||
|
return normalizedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionID)
|
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionID)
|
||||||
if (indexedDirectory) {
|
if (indexedDirectory && childStores.getChild(indexedDirectory)) {
|
||||||
return indexedDirectory
|
return indexedDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,10 +595,14 @@ const resolveDirectoryFromRoutingIndex = (
|
|||||||
|
|
||||||
const messageID = getMessageIdFromPayload(payload)
|
const messageID = getMessageIdFromPayload(payload)
|
||||||
if (messageID) {
|
if (messageID) {
|
||||||
|
if (normalizedDirectory && normalizedDirectory !== "global" && childStoreHasMessagePartState(childStores, normalizedDirectory, messageID)) {
|
||||||
|
return normalizedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
const sessionFromMessage = routingIndex.messageSessionById.get(messageID)
|
const sessionFromMessage = routingIndex.messageSessionById.get(messageID)
|
||||||
if (sessionFromMessage) {
|
if (sessionFromMessage) {
|
||||||
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionFromMessage)
|
const indexedDirectory = routingIndex.sessionDirectoryById.get(sessionFromMessage)
|
||||||
if (indexedDirectory) {
|
if (indexedDirectory && childStores.getChild(indexedDirectory)) {
|
||||||
return indexedDirectory
|
return indexedDirectory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -702,12 +694,11 @@ const updateRoutingIndexFromEvent = (
|
|||||||
/**
|
/**
|
||||||
* Re-fetch pending questions and permissions for a directory and merge them
|
* Re-fetch pending questions and permissions for a directory and merge them
|
||||||
* into the directory's child store, preserving any in-flight SSE updates that
|
* into the directory's child store, preserving any in-flight SSE updates that
|
||||||
* arrived while the request was pending. Shared between reconnect resync and
|
* arrived while the request was pending. Used by reconnect/materialization
|
||||||
* session-switch resync (the latter is a belt-and-suspenders backstop for any
|
* recovery paths only; normal session switches rely on primary SSE reducer
|
||||||
* code path that drops a `question.asked` / `permission.requested` event —
|
* state for `question.asked` / `permission.asked` events. When
|
||||||
* directory-eviction rehydration, cross-directory session switches, transport
|
* `candidateSessionIds` is omitted, every session known to the directory store
|
||||||
* fallback gaps, etc.). When `candidateSessionIds` is omitted, every session
|
* is treated as a candidate.
|
||||||
* known to the directory store is treated as a candidate.
|
|
||||||
*/
|
*/
|
||||||
export async function resyncBlockingRequestsForDirectory(
|
export async function resyncBlockingRequestsForDirectory(
|
||||||
directory: string,
|
directory: string,
|
||||||
@@ -725,8 +716,8 @@ export async function resyncBlockingRequestsForDirectory(
|
|||||||
const candidates = candidateSessionIds ?? Array.from(knownSessionIds)
|
const candidates = candidateSessionIds ?? Array.from(knownSessionIds)
|
||||||
if (candidates.length === 0) return
|
if (candidates.length === 0) return
|
||||||
|
|
||||||
// Re-fetch pending questions — they may have been asked during an SSE gap,
|
// Re-fetch pending questions that may have been asked during an SSE gap,
|
||||||
// a directory-eviction window, or a session-switch that bypassed bootstrap.
|
// reconnect window, or directory materialization gap.
|
||||||
try {
|
try {
|
||||||
const beforeSignatures = new Map(
|
const beforeSignatures = new Map(
|
||||||
candidates.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
candidates.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
||||||
@@ -870,7 +861,7 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const candidateSessionIds = getReconnectCandidateSessionIds(current, {
|
const candidateSessionIds = getReconnectCandidateSessionIds(current, {
|
||||||
directory,
|
directory,
|
||||||
viewedSession: getViewedSessionRecoveryTarget(directory),
|
viewedSession: getViewedSessionMaterializationTarget(directory),
|
||||||
})
|
})
|
||||||
if (candidateSessionIds.length === 0) return
|
if (candidateSessionIds.length === 0) return
|
||||||
|
|
||||||
@@ -940,36 +931,25 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
sessionChanged = true
|
sessionChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge parts: overwrite only messages present in the fetch snapshot.
|
const materialized = materializeSessionSnapshots(
|
||||||
// Do NOT delete parts for messages that may have been added by SSE
|
state,
|
||||||
// events arriving between the fetch and the setState — those are more recent.
|
sessionId,
|
||||||
let nextPartState = state.part
|
records.map((record) => ({
|
||||||
let partsChanged = false
|
info: stripMessageDiffSnapshots(record.info),
|
||||||
for (const record of records) {
|
parts: record.parts ?? [],
|
||||||
const messageId = record?.info?.id
|
})),
|
||||||
if (!messageId) continue
|
{ skipPartTypes: RECONNECT_SKIP_PARTS },
|
||||||
const nextParts = (record.parts ?? [])
|
)
|
||||||
.filter((part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
|
const messagesChanged = materialized.messagesChanged
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
const partsChanged = materialized.partsChanged
|
||||||
if (!haveEquivalentPartSnapshots(state.part[messageId], nextParts)) {
|
|
||||||
if (!partsChanged) {
|
|
||||||
nextPartState = { ...state.part }
|
|
||||||
partsChanged = true
|
|
||||||
}
|
|
||||||
nextPartState[messageId] = nextParts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMessages = mergeMessages(state.message[sessionId] ?? [], nextMessages)
|
|
||||||
const messagesChanged = mergedMessages !== (state.message[sessionId] ?? [])
|
|
||||||
if (!sessionChanged && !messagesChanged && !partsChanged) {
|
if (!sessionChanged && !messagesChanged && !partsChanged) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(sessionChanged ? { session: sessions, sessionTotal } : {}),
|
...(sessionChanged ? { session: sessions, sessionTotal } : {}),
|
||||||
...(messagesChanged ? { message: { ...state.message, [sessionId]: mergedMessages } } : {}),
|
...(messagesChanged ? { message: materialized.message } : {}),
|
||||||
...(partsChanged ? { part: nextPartState } : {}),
|
...(partsChanged ? { part: materialized.part } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1146,8 +1126,8 @@ function handleEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync-layer parent resync: when a child session goes idle, schedule
|
// Sync-layer parent resync: when a child session goes idle, recover
|
||||||
// a targeted parts repair for the parent session. This ensures the
|
// the parent session snapshot. This ensures the
|
||||||
// parent's task tool part reflects the child's completion even when
|
// parent's task tool part reflects the child's completion even when
|
||||||
// no ToolPart component is mounted.
|
// no ToolPart component is mounted.
|
||||||
if (payload.type === "session.idle") {
|
if (payload.type === "session.idle") {
|
||||||
@@ -1159,7 +1139,7 @@ function handleEvent(
|
|||||||
? (idleSession as Session & { parentID?: string | null }).parentID
|
? (idleSession as Session & { parentID?: string | null }).parentID
|
||||||
: null
|
: null
|
||||||
if (parentID) {
|
if (parentID) {
|
||||||
enqueuePartsRepair(resolvedDirectory, parentID, childStores)
|
enqueueSessionMaterialization(resolvedDirectory, parentID, childStores)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,24 +1200,28 @@ function handleEvent(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applyDirectoryEvent(draft, payload, {
|
const reducerResult = applyDirectoryEvent(draft, payload, {
|
||||||
onSetSessionTodo: (sessionID, todos) => {
|
onSetSessionTodo: (sessionID, todos) => {
|
||||||
useTodosPersistStore.getState().setSessionTodos(sessionID, todos)
|
useTodosPersistStore.getState().setSessionTodos(sessionID, todos)
|
||||||
},
|
},
|
||||||
})) {
|
})
|
||||||
|
const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed
|
||||||
|
const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization
|
||||||
|
|
||||||
|
if (reducerChanged) {
|
||||||
store.setState(draft)
|
store.setState(draft)
|
||||||
const sessionID = getSessionIdFromPayload(payload) ?? undefined
|
const sessionID = getSessionIdFromPayload(payload) ?? undefined
|
||||||
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
||||||
syncDebug.dispatch.eventApplied(payload.type, sessionID, messageID)
|
syncDebug.dispatch.eventApplied(payload.type, sessionID, messageID)
|
||||||
|
|
||||||
// Parts-gap recovery on message.updated: if the message was inserted or
|
// Snapshot materialization on message.updated: if the message was inserted or
|
||||||
// replaced but draft.part[messageID] is empty, the parts were lost or
|
// replaced but draft.part[messageID] is empty, the parts were lost or
|
||||||
// never arrived. Trigger repair so the UI doesn't render a blank bubble.
|
// never arrived. Recover the session so the UI doesn't render a blank bubble.
|
||||||
if (sessionID && messageID && payload.type === "message.updated") {
|
if (sessionID && messageID && payload.type === "message.updated") {
|
||||||
const after = store.getState()
|
const after = store.getState()
|
||||||
const info = (payload.properties as { info: Message }).info
|
const info = (payload.properties as { info: Message }).info
|
||||||
if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) {
|
if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) {
|
||||||
enqueuePartsRepair(resolvedDirectory, sessionID, childStores)
|
enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1245,13 +1229,14 @@ function handleEvent(
|
|||||||
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
||||||
syncDebug.dispatch.eventNoChange(payload.type, sessionID, messageID)
|
syncDebug.dispatch.eventNoChange(payload.type, sessionID, messageID)
|
||||||
|
|
||||||
// Parts-gap recovery: if a delta event was dropped because the parts array
|
}
|
||||||
// was missing or the partID was not found, trigger a repair fetch for the
|
|
||||||
// session. message.part.updated never needs repair — it only returns false
|
// Snapshot materialization is driven by typed reducer outcomes, not by
|
||||||
// for intentionally skipped types (step-start, step-finish, patch) or when
|
// inferring meaning from a generic false/no-change result.
|
||||||
// preserving an existing finished tool part, neither of which indicates missing data.
|
if (materializationResult) {
|
||||||
if (sessionID && messageID && payload.type === "message.part.delta") {
|
const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined
|
||||||
enqueuePartsRepair(resolvedDirectory, sessionID, childStores)
|
if (materializationSessionID) {
|
||||||
|
enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1395,20 +1380,20 @@ export function SyncProvider(props: {
|
|||||||
// Event pipeline — created once per mount. No class, no start/stop.
|
// Event pipeline — created once per mount. No class, no start/stop.
|
||||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reconnectResyncing = new Set<string>()
|
const reconnectMaterializing = new Set<string>()
|
||||||
const triggerRecoveryResync = (directory: string) => {
|
const triggerReconnectMaterialization = (directory: string) => {
|
||||||
const store = childStores.children.get(directory)
|
const store = childStores.children.get(directory)
|
||||||
if (!store) return
|
if (!store) return
|
||||||
if (reconnectResyncing.has(directory)) return
|
if (reconnectMaterializing.has(directory)) return
|
||||||
|
|
||||||
reconnectResyncing.add(directory)
|
reconnectMaterializing.add(directory)
|
||||||
void resyncDirectoryAfterReconnect(directory, store, routingIndex)
|
void resyncDirectoryAfterReconnect(directory, store, routingIndex)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Transient failure during resync — next SSE event, transport switch,
|
// Transient failure during materialization — next SSE event, transport switch,
|
||||||
// or reconnect will catch up.
|
// or reconnect will catch up.
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
reconnectResyncing.delete(directory)
|
reconnectMaterializing.delete(directory)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1428,7 +1413,7 @@ export function SyncProvider(props: {
|
|||||||
connectionPhase: "connected",
|
connectionPhase: "connected",
|
||||||
})
|
})
|
||||||
for (const dir of childStores.children.keys()) {
|
for (const dir of childStores.children.keys()) {
|
||||||
triggerRecoveryResync(dir)
|
triggerReconnectMaterialization(dir)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDisconnect: (reason) => {
|
onDisconnect: (reason) => {
|
||||||
@@ -1449,7 +1434,7 @@ export function SyncProvider(props: {
|
|||||||
connectionPhase: "connected",
|
connectionPhase: "connected",
|
||||||
})
|
})
|
||||||
if (_activeDirectory) {
|
if (_activeDirectory) {
|
||||||
triggerRecoveryResync(_activeDirectory)
|
triggerReconnectMaterialization(_activeDirectory)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1487,46 +1472,6 @@ export function SyncProvider(props: {
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [props.directory, childStores])
|
}, [props.directory, childStores])
|
||||||
|
|
||||||
// Re-fetch pending questions/permissions on session-switch.
|
|
||||||
// PR #909 only re-fetches on SSE reconnect, leaving an event-drop gap when
|
|
||||||
// switching sessions within the same socket — the question.asked event may
|
|
||||||
// have arrived while a different session was active and the directory store
|
|
||||||
// was evicted, or the user may navigate back to a directory whose store was
|
|
||||||
// rebuilt after eviction. A 250ms debounce coalesces rapid switches.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let lastSessionId: string | null = null
|
|
||||||
let unsub: (() => void) | undefined
|
|
||||||
void import("./session-ui-store")
|
|
||||||
.then(({ useSessionUIStore }) => {
|
|
||||||
if (cancelled) return
|
|
||||||
lastSessionId = useSessionUIStore.getState().currentSessionId
|
|
||||||
unsub = useSessionUIStore.subscribe((state) => {
|
|
||||||
const nextSessionId = state.currentSessionId
|
|
||||||
if (nextSessionId === lastSessionId) return
|
|
||||||
lastSessionId = nextSessionId
|
|
||||||
if (!nextSessionId) return
|
|
||||||
const sessionDirectory = state.getDirectoryForSession(nextSessionId)
|
|
||||||
?? opencodeClient.getDirectory()
|
|
||||||
?? props.directory
|
|
||||||
if (!sessionDirectory) return
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
const currentStore = childStores.getChild(sessionDirectory)
|
|
||||||
if (!currentStore) return
|
|
||||||
void resyncBlockingRequestsForDirectory(sessionDirectory, currentStore).catch(() => undefined)
|
|
||||||
}, 250)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
unsub?.()
|
|
||||||
}
|
|
||||||
}, [props.directory, childStores])
|
|
||||||
|
|
||||||
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
|
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1997,8 +1942,8 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
// Already loaded — nothing to do
|
// Already loaded into a renderable message/part snapshot — nothing to do.
|
||||||
if (Object.prototype.hasOwnProperty.call(state.message, sessionID)) return
|
if (getSessionMaterializationStatus(state, sessionID).renderable) return
|
||||||
// Session doesn't exist — nothing to load
|
// Session doesn't exist — nothing to load
|
||||||
if (!state.session.some((s) => s.id === sessionID)) return
|
if (!state.session.some((s) => s.id === sessionID)) return
|
||||||
|
|
||||||
@@ -2011,38 +1956,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const scopedClient = opencodeClient.getScopedSdkClient(dir ?? "")
|
await materializeSessionFromServer(dir ?? "", sessionID, store)
|
||||||
const response = await scopedClient.session.messages({
|
|
||||||
sessionID: sessionID,
|
|
||||||
limit: RECONNECT_MESSAGE_LIMIT,
|
|
||||||
})
|
|
||||||
const records = (response.data ?? []).filter(
|
|
||||||
(record: { info?: { id?: string } }) => !!record?.info?.id,
|
|
||||||
)
|
|
||||||
if (records.length === 0) return
|
|
||||||
|
|
||||||
const nextMessages = records
|
|
||||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
|
||||||
.filter((m: Message | null): m is Message => m !== null)
|
|
||||||
.sort((a: Message, b: Message) => cmp(a.id, b.id))
|
|
||||||
|
|
||||||
const nextPartState: Record<string, Part[]> = {}
|
|
||||||
for (const record of records) {
|
|
||||||
const messageId = record?.info?.id
|
|
||||||
if (!messageId) continue
|
|
||||||
nextPartState[messageId] = (record.parts ?? [])
|
|
||||||
.filter((part: Part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
|
|
||||||
.sort((a: Part, b: Part) => cmp(a.id, b.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
store.setState((state: DirectoryStore) => {
|
|
||||||
const currentMessages = state.message[sessionID] ?? []
|
|
||||||
const mergedMessages = mergeMessages(currentMessages, nextMessages)
|
|
||||||
return {
|
|
||||||
message: mergedMessages !== currentMessages ? { ...state.message, [sessionID]: mergedMessages } : state.message,
|
|
||||||
part: { ...state.part, ...nextPartState },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
// Transient failure — next navigation or reconnect will retry
|
// Transient failure — next navigation or reconnect will retry
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { ChildStoreManager } from "./child-store"
|
import type { ChildStoreManager } from "./child-store"
|
||||||
|
import { getSessionMaterializationStatus } from "./materialization"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
|
|
||||||
let _sdk: OpencodeClient | null = null
|
let _sdk: OpencodeClient | null = null
|
||||||
@@ -83,6 +84,13 @@ export function getSyncMessages(sessionId: string, directory?: string) {
|
|||||||
return getDirectoryState(directory)?.message[sessionId] ?? []
|
return getDirectoryState(directory)?.message[sessionId] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read renderability of a session snapshot from current directory's child store */
|
||||||
|
export function getSyncSessionMaterializationStatus(sessionId: string, directory?: string) {
|
||||||
|
const state = getDirectoryState(directory)
|
||||||
|
if (!state) return { hasMessages: false, renderable: false, missingPartMessageIDs: [] }
|
||||||
|
return getSessionMaterializationStatus(state, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
/** Read parts for a message from current directory's child store */
|
/** Read parts for a message from current directory's child store */
|
||||||
export function getSyncParts(messageId: string, directory?: string) {
|
export function getSyncParts(messageId: string, directory?: string) {
|
||||||
return getDirectoryState(directory)?.part[messageId] ?? []
|
return getDirectoryState(directory)?.part[messageId] ?? []
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { SESSION_CACHE_LIMIT } from "./types"
|
|||||||
import { pickSessionCacheEvictions } from "./session-cache"
|
import { pickSessionCacheEvictions } from "./session-cache"
|
||||||
import {
|
import {
|
||||||
mergeOptimisticPage,
|
mergeOptimisticPage,
|
||||||
mergeMessages,
|
|
||||||
type OptimisticItem,
|
type OptimisticItem,
|
||||||
} from "./optimistic"
|
} from "./optimistic"
|
||||||
import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
clearSessionPrefetch,
|
clearSessionPrefetch,
|
||||||
} from "./session-prefetch-cache"
|
} from "./session-prefetch-cache"
|
||||||
|
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const MESSAGE_PAGE_SIZE = 200
|
const MESSAGE_PAGE_SIZE = 200
|
||||||
@@ -224,33 +224,19 @@ export function useSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const cached = current.message[sessionID] ?? []
|
const materialized = materializeSessionSnapshots(
|
||||||
const messages = options?.mode === "prepend"
|
current,
|
||||||
? mergeMessages(cached, merged.session)
|
sessionID,
|
||||||
: (cached.length > 0 ? mergeMessages(cached, merged.session) : merged.session)
|
merged.session.map((info) => ({
|
||||||
|
info,
|
||||||
|
parts: merged.part.find((item) => item.id === info.id)?.part ?? [],
|
||||||
|
})),
|
||||||
|
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
|
||||||
|
)
|
||||||
|
|
||||||
// Build part updates — preserve existing references on prepend to avoid flicker
|
store.setState({ message: materialized.message, part: materialized.part })
|
||||||
const isPrepend = options?.mode === "prepend"
|
|
||||||
let partsChanged = false
|
|
||||||
const partUpdate: Record<string, Part[]> = { ...current.part }
|
|
||||||
for (const p of merged.part) {
|
|
||||||
if (isPrepend && partUpdate[p.id]) continue // already loaded
|
|
||||||
const filtered = p.part.filter((x: Part) => !SKIP_PARTS.has(x.type))
|
|
||||||
if (filtered.length) {
|
|
||||||
partUpdate[p.id] = filtered
|
|
||||||
partsChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch: Record<string, unknown> = {
|
|
||||||
message: messages !== cached ? { ...current.message, [sessionID]: messages } : current.message,
|
|
||||||
}
|
|
||||||
if (!isPrepend || partsChanged) {
|
|
||||||
patch.part = partUpdate
|
|
||||||
}
|
|
||||||
store.setState(patch)
|
|
||||||
setMetaFor(sessionID, {
|
setMetaFor(sessionID, {
|
||||||
limit: messages.length,
|
limit: materialized.messages.length,
|
||||||
cursor: merged.cursor,
|
cursor: merged.cursor,
|
||||||
complete: merged.complete,
|
complete: merged.complete,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -258,7 +244,7 @@ export function useSync() {
|
|||||||
setSessionPrefetch({
|
setSessionPrefetch({
|
||||||
directory,
|
directory,
|
||||||
sessionID,
|
sessionID,
|
||||||
limit: messages.length,
|
limit: materialized.messages.length,
|
||||||
cursor: merged.cursor,
|
cursor: merged.cursor,
|
||||||
complete: merged.complete,
|
complete: merged.complete,
|
||||||
})
|
})
|
||||||
@@ -281,7 +267,8 @@ export function useSync() {
|
|||||||
|
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const m = getMetaFor(sessionID)
|
const m = getMetaFor(sessionID)
|
||||||
const cached = current.message[sessionID] !== undefined && m.limit > 0
|
const materialization = getSessionMaterializationStatus(current, sessionID)
|
||||||
|
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
|
||||||
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
|
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
|
||||||
if (cached && hasSession && !force) return
|
if (cached && hasSession && !force) return
|
||||||
|
|
||||||
@@ -401,6 +388,7 @@ export function useSync() {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
ensureSessionRenderable: syncSession,
|
||||||
syncSession,
|
syncSession,
|
||||||
loadMore,
|
loadMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
|||||||
Reference in New Issue
Block a user