perf: optimize session loading and desktop startup (#2545)
* perf: optimize session loading and startup * fix(chat): stabilize history prepend virtualization * perf: unblock first session open from startup network contention Opening the first session after app start waited seconds for its message fetch. Three independent contributors, each measured via CDP network capture and Chromium net-log against the packaged desktop app: - The active-session watchdog fired an uncapped per-directory status poll and child-session discovery burst at startup, and other subsystems (git checks, global session pages, command/skill discovery) fanned out alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin. Add a shared background-network gate (concurrency 3) and route the watchdog, poll-shaped git reads (also priority: low), global session pages, command/skill loads, and the background update check through it. - The packaged renderer is cross-origin to the loopback backend, so every API call needs a CORS preflight; a few slow OpenCode-proxied requests held the whole pool while preflights and interactive traffic queued behind them. Lift Chromium's per-host connection cap for loopback via ignore-connections-limit in the Electron shell. - OpenCode initializes each directory lazily on its first request, so the first click paid that cost interactively. Warm the last-used directory and the three most recently opened projects right after OpenCode readiness, sequentially and best-effort, overlapping UI startup. Validation: new background-network tests, lifecycle warmup test, focused store/sync tests, UI type-check and lint, dead-code report, node --check plus electron type-check/lint, and CDP first-open measurements on the packaged app (message fetch socket queue 5.4s -> 0.03s). * fix(ui): keep interactive git reads out of background queue --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
09f0c64839
commit
aae889b904
@@ -55,6 +55,8 @@ import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
@@ -140,6 +142,7 @@ type HydratingToolSkeletonRow = {
|
||||
|
||||
type ChatViewportProps = {
|
||||
currentSessionId: string;
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
@@ -178,6 +181,7 @@ type ChatViewportProps = {
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
@@ -345,7 +349,7 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionId}
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
@@ -398,6 +402,7 @@ const ChatViewport = React.memo(({
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.currentSessionId === next.currentSessionId
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
@@ -542,14 +547,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
const sync = useSync();
|
||||
const syncDirectory = useSyncDirectory();
|
||||
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
||||
const currentSessionKey = currentSessionId
|
||||
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
|
||||
: null;
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
const loadMoreMessages = React.useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
|
||||
[sync],
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
|
||||
// UI store
|
||||
@@ -589,6 +597,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return;
|
||||
return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length);
|
||||
}, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]);
|
||||
|
||||
// Plan detection - watches messages for plan creation and signals store
|
||||
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
||||
@@ -779,6 +793,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
@@ -789,6 +804,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
|
||||
const timelineController = useChatTimelineController({
|
||||
sessionId: currentSessionId,
|
||||
sessionKey: currentSessionKey,
|
||||
messages: viewportMessages,
|
||||
historyMeta,
|
||||
scrollRef,
|
||||
@@ -940,7 +956,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
};
|
||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||
|
||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||
const lastScrolledSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
@@ -952,10 +968,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionRef.current === currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
@@ -970,7 +986,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
@@ -1138,6 +1154,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
|
||||
@@ -1483,10 +1483,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return { ...entry, nextEntryFirstMessage };
|
||||
});
|
||||
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
||||
// All surfaces virtualize with @tanstack/react-virtual (see the engine
|
||||
// note at the top of the file). An unvirtualized list is kept only for
|
||||
// tiny histories where windowing overhead is not worth it.
|
||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
// Mobile always starts with the same virtualized engine it will use after
|
||||
// pagination. Switching a short list from normal DOM to TanStack during a
|
||||
// prepend remounts the history subtree, and the newly enabled end-anchored
|
||||
// virtualizer initializes at the bottom before it has prior keyed state.
|
||||
// Desktop keeps the small-list threshold where that transition is not tied
|
||||
// to the explicit mobile load-older interaction.
|
||||
const shouldVirtualizeHistory = isMobileSurfaceRuntime()
|
||||
|| historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
import {
|
||||
isOlderHistoryPrependCommit,
|
||||
shouldAutoLoadEarlierForUnderfilledPinnedViewport,
|
||||
useChatTimelineController,
|
||||
type UseChatTimelineControllerResult,
|
||||
} from './useChatTimelineController';
|
||||
import type { MessageListHandle } from '../MessageList';
|
||||
|
||||
const baseInput = {
|
||||
sessionId: 'ses_1',
|
||||
@@ -68,3 +74,185 @@ describe('isOlderHistoryPrependCommit', () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
|
||||
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('useChatTimelineController identity lifecycle', () => {
|
||||
test('preserves the new identity while an old load is waiting for its render', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const pendingA = deferred();
|
||||
const pendingB = deferred();
|
||||
const calls: string[] = [];
|
||||
const sessionId = 'shared-session';
|
||||
const message = {
|
||||
info: { id: 'msg_1', sessionID: sessionId, role: 'user', time: { created: 1 } } as Message,
|
||||
parts: [],
|
||||
};
|
||||
const olderMessage = {
|
||||
info: { id: 'msg_0', sessionID: sessionId, role: 'user', time: { created: 0 } } as Message,
|
||||
parts: [],
|
||||
};
|
||||
const assistantMessage = {
|
||||
info: { id: 'msg_2', sessionID: sessionId, role: 'assistant', time: { created: 2 } } as Message,
|
||||
parts: [],
|
||||
};
|
||||
const scrollMetrics = {
|
||||
scrollTop: 100,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
firstElementChild: null,
|
||||
};
|
||||
const scrollElement = scrollMetrics as unknown as HTMLDivElement;
|
||||
const scrollRef = { current: scrollElement };
|
||||
const capturedAnchors: string[] = [];
|
||||
const restoredAnchors: string[] = [];
|
||||
const messageListRef = {
|
||||
current: {
|
||||
captureViewportAnchor: () => {
|
||||
const messageId = `anchor-${directory}`;
|
||||
capturedAnchors.push(messageId);
|
||||
return { messageId, offsetTop: 0 };
|
||||
},
|
||||
restoreViewportAnchor: (anchor: { messageId: string }) => {
|
||||
restoredAnchors.push(anchor.messageId);
|
||||
return true;
|
||||
},
|
||||
isHistoryVirtualized: () => false,
|
||||
scrollToTurnId: () => false,
|
||||
scrollToMessageId: () => false,
|
||||
} as unknown as MessageListHandle,
|
||||
};
|
||||
let controller!: UseChatTimelineControllerResult;
|
||||
let directory = 'A';
|
||||
let messages = [message];
|
||||
let startBOnLayout = false;
|
||||
let loadB: Promise<void> | null = null;
|
||||
|
||||
const Harness = () => {
|
||||
const selectedDirectory = directory;
|
||||
controller = useChatTimelineController({
|
||||
sessionId,
|
||||
sessionKey: `runtime\n${selectedDirectory}\n${sessionId}`,
|
||||
messages,
|
||||
historyMeta: { limit: 1, complete: false, loading: false },
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
loadMoreMessages: async () => {
|
||||
calls.push(selectedDirectory);
|
||||
await (selectedDirectory === 'A' ? pendingA.promise : pendingB.promise);
|
||||
},
|
||||
goToBottom: () => undefined,
|
||||
releaseAutoFollow: () => undefined,
|
||||
isPinned: false,
|
||||
showScrollButton: false,
|
||||
});
|
||||
React.useLayoutEffect(() => {
|
||||
if (selectedDirectory === 'B' && startBOnLayout && !loadB) {
|
||||
loadB = controller.loadEarlier({ userInitiated: true });
|
||||
}
|
||||
}, [selectedDirectory]);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
let loadA!: Promise<void>;
|
||||
act(() => {
|
||||
loadA = controller.loadEarlier({ userInitiated: true });
|
||||
});
|
||||
expect(calls).toEqual(['A']);
|
||||
|
||||
// Let A pass its post-network identity check and enter the render
|
||||
// waiter before switching. B starts in the same layout commit that
|
||||
// releases A's waiter, so A must not clear B's new snapshot.
|
||||
await act(async () => {
|
||||
pendingA.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
directory = 'B';
|
||||
// Growth within the existing user turn means stale A would request
|
||||
// another A page after its render wait without the second token gate.
|
||||
messages = [message, assistantMessage];
|
||||
startBOnLayout = true;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await loadA;
|
||||
});
|
||||
expect(calls).toEqual(['A', 'B']);
|
||||
expect(controller.isLoadingOlder).toBe(true);
|
||||
expect(capturedAnchors).toContain('anchor-B');
|
||||
expect(restoredAnchors).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
pendingB.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
messages = [olderMessage, message, assistantMessage];
|
||||
scrollMetrics.scrollHeight = 1200;
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
await act(async () => {
|
||||
await loadB;
|
||||
});
|
||||
expect(controller.isLoadingOlder).toBe(false);
|
||||
expect(calls).toEqual(['A', 'B']);
|
||||
expect(restoredAnchors).toEqual(['anchor-B']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,10 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
|
||||
type ViewportAnchor = { messageId: string; offsetTop: number };
|
||||
type TimelineIdentityToken = { key: string | null };
|
||||
|
||||
type PendingScrollRequest = {
|
||||
sessionId: string;
|
||||
identity: TimelineIdentityToken;
|
||||
kind: 'turn' | 'message';
|
||||
id: string;
|
||||
behavior: ScrollBehavior;
|
||||
@@ -25,6 +26,7 @@ type PendingScrollRequest = {
|
||||
|
||||
interface UseChatTimelineControllerOptions {
|
||||
sessionId: string | null;
|
||||
sessionKey: string | null;
|
||||
messages: ChatMessageEntry[];
|
||||
historyMeta: SessionHistoryMeta | null;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -192,6 +194,7 @@ const hasInsertedBeforeKnownOldest = (
|
||||
|
||||
export const useChatTimelineController = ({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
messages,
|
||||
historyMeta,
|
||||
scrollRef,
|
||||
@@ -204,8 +207,14 @@ export const useChatTimelineController = ({
|
||||
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
||||
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
||||
const previousTurnWindowKeyRef = React.useRef<string | null>(null);
|
||||
const turnWindowModel = React.useMemo(() => {
|
||||
const key = sessionId ?? ""
|
||||
const key = sessionKey ?? ""
|
||||
if (previousTurnWindowKeyRef.current !== sessionKey) {
|
||||
previousTurnWindowKeyRef.current = sessionKey;
|
||||
previousTurnWindowModelRef.current = null;
|
||||
previousMessagesRef.current = null;
|
||||
}
|
||||
const cached = key ? turnModelCache.get(key) : undefined
|
||||
if (cached && cached.messages === messages) {
|
||||
rememberTurnModel(key, cached)
|
||||
@@ -228,7 +237,7 @@ export const useChatTimelineController = ({
|
||||
}
|
||||
|
||||
return nextModel;
|
||||
}, [messages, sessionId]);
|
||||
}, [messages, sessionKey]);
|
||||
|
||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||
const [pendingRevealWork, setPendingRevealWork] = React.useState(false);
|
||||
@@ -239,9 +248,13 @@ export const useChatTimelineController = ({
|
||||
const isLoadingOlderRef = React.useRef(isLoadingOlder);
|
||||
const pendingRevealWorkRef = React.useRef(pendingRevealWork);
|
||||
const sessionIdRef = React.useRef<string | null>(sessionId);
|
||||
const timelineIdentityRef = React.useRef<TimelineIdentityToken>({ key: sessionKey });
|
||||
if (timelineIdentityRef.current.key !== sessionKey) {
|
||||
timelineIdentityRef.current = { key: sessionKey };
|
||||
}
|
||||
const messagesRef = React.useRef(messages);
|
||||
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
|
||||
const initializedSessionRef = React.useRef<string | null>(null);
|
||||
const initializedSessionKeyRef = React.useRef<string | null>(null);
|
||||
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
||||
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
||||
const scrollPinRef = React.useRef<{ turnId: string; expiresAt: number } | null>(null);
|
||||
@@ -298,7 +311,7 @@ export const useChatTimelineController = ({
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (initializedSessionRef.current === sessionId) {
|
||||
if (initializedSessionKeyRef.current === sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
||||
@@ -306,12 +319,17 @@ export const useChatTimelineController = ({
|
||||
historyInteractionTimerRef.current = null;
|
||||
}
|
||||
historyInteractionRef.current = false;
|
||||
initializedSessionRef.current = sessionId;
|
||||
initializedSessionKeyRef.current = sessionKey;
|
||||
const pendingScroll = pendingScrollRequestRef.current;
|
||||
if (pendingScroll && pendingScroll.identity !== timelineIdentityRef.current) {
|
||||
pendingScrollRequestRef.current = null;
|
||||
pendingScroll.resolve(false);
|
||||
}
|
||||
setIsLoadingOlder(false);
|
||||
setPendingRevealWork(false);
|
||||
scrollPinRef.current = null;
|
||||
setActiveTurnId(null);
|
||||
}, [sessionId]);
|
||||
}, [sessionKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isPinned) {
|
||||
@@ -370,7 +388,7 @@ export const useChatTimelineController = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.sessionId !== sessionIdRef.current) {
|
||||
if (pending.identity !== timelineIdentityRef.current) {
|
||||
resolvePendingScrollRequest(false);
|
||||
return;
|
||||
}
|
||||
@@ -426,10 +444,11 @@ export const useChatTimelineController = ({
|
||||
// before triggering the state change. useLayoutEffect consumes it
|
||||
// after React commits new DOM — before the browser paints.
|
||||
const prePrependScrollRef = React.useRef<{
|
||||
sessionId: string | null;
|
||||
identity: TimelineIdentityToken;
|
||||
height: number;
|
||||
top: number;
|
||||
anchor: ViewportAnchor | null;
|
||||
historyVirtualized: boolean;
|
||||
oldestId: string | null;
|
||||
newestId: string | null;
|
||||
} | null>(null);
|
||||
@@ -457,7 +476,7 @@ export const useChatTimelineController = ({
|
||||
React.useLayoutEffect(() => {
|
||||
prePrependScrollRef.current = null;
|
||||
prependTrackingRef.current = null;
|
||||
}, [sessionId]);
|
||||
}, [sessionKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
@@ -480,7 +499,7 @@ export const useChatTimelineController = ({
|
||||
}) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages)
|
||||
: false;
|
||||
|
||||
if (snap && snap.sessionId !== sessionIdRef.current) {
|
||||
if (snap && snap.identity !== timelineIdentityRef.current) {
|
||||
prePrependScrollRef.current = null;
|
||||
snap = null;
|
||||
}
|
||||
@@ -544,18 +563,30 @@ export const useChatTimelineController = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// When the history list is virtualized, virtua runs with `shift` during
|
||||
// history loads and compensates the prepend internally. Manual
|
||||
// height-delta compensation on top of that applies the same delta twice
|
||||
// and throws the viewport far downward. Anchor restore stays allowed —
|
||||
// it corrects to an absolute element position, so it cannot double up.
|
||||
// TanStack owns every scroll adjustment for virtualized history. It
|
||||
// preserves stable keyed items across prepends and reconciles later row
|
||||
// measurements. Restoring the DOM anchor here as well creates a second
|
||||
// writer: depending on whether measurement has landed, it can apply the
|
||||
// same prepend delta twice or fall back to scrollToIndex against the new
|
||||
// indexes, throwing the viewport far downward.
|
||||
const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false;
|
||||
|
||||
if (snap && shouldConsumeSnapshot) {
|
||||
prePrependScrollRef.current = null;
|
||||
if (historyVirtualized) {
|
||||
// The newly enabled virtualizer has no prior keyed state for the
|
||||
// threshold-crossing commit, so allow one anchor restore. Once
|
||||
// already virtualized, TanStack is the sole scroll owner.
|
||||
if (!snap.historyVirtualized && snap.anchor) {
|
||||
restoreViewportAnchor(snap.anchor);
|
||||
}
|
||||
updateTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
const heightDelta = container.scrollHeight - snap.height;
|
||||
const applyHeightDelta = (): boolean => {
|
||||
if (historyVirtualized || heightDelta <= 0) {
|
||||
if (heightDelta <= 0) {
|
||||
return false;
|
||||
}
|
||||
container.scrollTop = snap.top + heightDelta;
|
||||
@@ -563,38 +594,21 @@ export const useChatTimelineController = ({
|
||||
};
|
||||
|
||||
// Non-virtualized mobile list only: fight iOS momentum manually.
|
||||
// The virtualized mobile list (tanstack) defers prepend adjustments
|
||||
// through touch/momentum in core, so manual writes would double up.
|
||||
if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) {
|
||||
if (isMobileSurfaceRuntime() && heightDelta > 0) {
|
||||
setScrollTopDefeatingMomentum(container, snap.top + heightDelta);
|
||||
updateTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
// When a viewport anchor is available, delegate to MessageList
|
||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
||||
// Note: an unchanged scrollTop after restore is NOT a failure here —
|
||||
// the virtualized list compensates the prepend internally, so
|
||||
// staying near snap.top is the correct outcome.
|
||||
// The unvirtualized list has no internal prepend compensation.
|
||||
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
||||
// Fallback: height-delta compensation
|
||||
applyHeightDelta();
|
||||
}
|
||||
if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) {
|
||||
// Mobile only: freshly prepended rows keep re-measuring for a
|
||||
// few frames and each pass can shift content, so hold the
|
||||
// anchor until it settles. Desktop must NOT run this — wheel
|
||||
// scrolling during the hold would fight the re-assertions and
|
||||
// read as a frozen scroll; the virtualizer's own anchoring is
|
||||
// enough there.
|
||||
messageListRef.current?.holdViewportAnchor(snap.anchor);
|
||||
}
|
||||
} else if (isPrepend && prev && !historyVirtualized) {
|
||||
// Released viewport: preserve the read position by compensating for the
|
||||
// exact height the prepend added above, with no intermediate frame for
|
||||
// auto-follow to fight. Virtualized lists skip this — virtua `shift`
|
||||
// already compensated the prepend.
|
||||
// auto-follow to fight. Virtualized lists skip this because TanStack
|
||||
// already owns keyed prepend preservation.
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
const target = container.scrollTop + delta;
|
||||
@@ -619,13 +633,24 @@ export const useChatTimelineController = ({
|
||||
const fetchOlderHistory = React.useCallback(async (input: {
|
||||
preserveViewport: boolean;
|
||||
}): Promise<boolean> => {
|
||||
if (!sessionIdRef.current || isLoadingOlderRef.current) {
|
||||
if (!sessionIdRef.current || !timelineIdentityRef.current.key || isLoadingOlderRef.current) {
|
||||
return false;
|
||||
}
|
||||
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetSessionId = sessionIdRef.current;
|
||||
const targetIdentity = timelineIdentityRef.current;
|
||||
if (!targetSessionId || !targetIdentity.key) {
|
||||
return false;
|
||||
}
|
||||
const clearOwnedPrependSnapshot = () => {
|
||||
if (prePrependScrollRef.current?.identity === targetIdentity) {
|
||||
prePrependScrollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const container = scrollRef.current;
|
||||
const beforeMessages = messagesRef.current;
|
||||
const beforeMessageCount = beforeMessages.length;
|
||||
@@ -636,10 +661,11 @@ export const useChatTimelineController = ({
|
||||
// compensate synchronously when React commits the new messages.
|
||||
if (input.preserveViewport && container) {
|
||||
prePrependScrollRef.current = {
|
||||
sessionId: sessionIdRef.current,
|
||||
identity: targetIdentity,
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
anchor: captureViewportAnchor(),
|
||||
historyVirtualized: messageListRef.current?.isHistoryVirtualized() ?? false,
|
||||
oldestId: beforeOldestMessageId,
|
||||
newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
|
||||
};
|
||||
@@ -649,12 +675,6 @@ export const useChatTimelineController = ({
|
||||
setIsLoadingOlder(true);
|
||||
|
||||
try {
|
||||
const targetSessionId = sessionIdRef.current;
|
||||
if (!targetSessionId) {
|
||||
prePrependScrollRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
let loadedMessageCount = beforeMessageCount;
|
||||
let loadedOldestMessageId = beforeOldestMessageId;
|
||||
let loadedLimit = beforeLimit;
|
||||
@@ -662,12 +682,16 @@ export const useChatTimelineController = ({
|
||||
|
||||
while (true) {
|
||||
await loadMoreMessages(targetSessionId, 'up');
|
||||
if (sessionIdRef.current !== targetSessionId) {
|
||||
prePrependScrollRef.current = null;
|
||||
if (timelineIdentityRef.current !== targetIdentity) {
|
||||
clearOwnedPrependSnapshot();
|
||||
return false;
|
||||
}
|
||||
|
||||
await waitForNextRenderCommitOrTimeout();
|
||||
if (timelineIdentityRef.current !== targetIdentity) {
|
||||
clearOwnedPrependSnapshot();
|
||||
return false;
|
||||
}
|
||||
|
||||
const afterMessages = messagesRef.current;
|
||||
const afterMessageCount = afterMessages.length;
|
||||
@@ -685,7 +709,7 @@ export const useChatTimelineController = ({
|
||||
return true;
|
||||
}
|
||||
if (!messageGrowth) {
|
||||
prePrependScrollRef.current = null;
|
||||
clearOwnedPrependSnapshot();
|
||||
return false;
|
||||
}
|
||||
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
||||
@@ -697,15 +721,18 @@ export const useChatTimelineController = ({
|
||||
loadedLimit = afterLimit;
|
||||
}
|
||||
} catch (error) {
|
||||
prePrependScrollRef.current = null;
|
||||
clearOwnedPrependSnapshot();
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
settleHistoryInteraction();
|
||||
if (timelineIdentityRef.current === targetIdentity) {
|
||||
setIsLoadingOlder(false);
|
||||
settleHistoryInteraction();
|
||||
}
|
||||
}
|
||||
}, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]);
|
||||
}, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, messageListRef, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]);
|
||||
|
||||
const loadEarlier = React.useCallback(async (options?: { userInitiated?: boolean }) => {
|
||||
const targetIdentity = timelineIdentityRef.current;
|
||||
beginHistoryInteraction();
|
||||
if (options?.userInitiated) {
|
||||
releaseAutoFollow();
|
||||
@@ -714,7 +741,9 @@ export const useChatTimelineController = ({
|
||||
try {
|
||||
void (await fetchOlderHistory({ preserveViewport: true }));
|
||||
} finally {
|
||||
settleHistoryInteraction();
|
||||
if (timelineIdentityRef.current === targetIdentity) {
|
||||
settleHistoryInteraction();
|
||||
}
|
||||
}
|
||||
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
|
||||
|
||||
@@ -775,7 +804,7 @@ export const useChatTimelineController = ({
|
||||
loadEarlierIfPinnedViewportUnderfilled,
|
||||
pendingRevealWork,
|
||||
renderedMessages.length,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -813,21 +842,22 @@ export const useChatTimelineController = ({
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionId]);
|
||||
}, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionKey]);
|
||||
|
||||
const scrollToTurn = React.useCallback(async (
|
||||
turnId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
): Promise<boolean> => {
|
||||
if (!turnId || !sessionIdRef.current) {
|
||||
if (!turnId || !sessionIdRef.current || !timelineIdentityRef.current.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetIdentity = timelineIdentityRef.current;
|
||||
releaseAutoFollow();
|
||||
setPendingRevealWork(true);
|
||||
|
||||
try {
|
||||
if (sessionIdRef.current !== sessionId) {
|
||||
if (timelineIdentityRef.current !== targetIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -838,7 +868,7 @@ export const useChatTimelineController = ({
|
||||
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
pendingScrollRequestRef.current = {
|
||||
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||
identity: targetIdentity,
|
||||
kind: 'turn',
|
||||
id: turnId,
|
||||
behavior: options?.behavior ?? 'auto',
|
||||
@@ -854,23 +884,26 @@ export const useChatTimelineController = ({
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
if (timelineIdentityRef.current === targetIdentity) {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow]);
|
||||
|
||||
const scrollToMessage = React.useCallback(async (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
): Promise<boolean> => {
|
||||
if (!messageId || !sessionIdRef.current) {
|
||||
if (!messageId || !sessionIdRef.current || !timelineIdentityRef.current.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetIdentity = timelineIdentityRef.current;
|
||||
releaseAutoFollow();
|
||||
setPendingRevealWork(true);
|
||||
|
||||
try {
|
||||
if (sessionIdRef.current !== sessionId) {
|
||||
if (timelineIdentityRef.current !== targetIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -883,7 +916,7 @@ export const useChatTimelineController = ({
|
||||
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
pendingScrollRequestRef.current = {
|
||||
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
||||
identity: targetIdentity,
|
||||
kind: 'message',
|
||||
id: messageId,
|
||||
behavior: options?.behavior ?? 'auto',
|
||||
@@ -899,9 +932,11 @@ export const useChatTimelineController = ({
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setPendingRevealWork(false);
|
||||
if (timelineIdentityRef.current === targetIdentity) {
|
||||
setPendingRevealWork(false);
|
||||
}
|
||||
}
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
||||
}, [attemptPendingScrollRequest, releaseAutoFollow]);
|
||||
|
||||
const resumeToBottom = React.useCallback(async () => {
|
||||
setPendingRevealWork(false);
|
||||
|
||||
@@ -96,6 +96,7 @@ import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands'
|
||||
import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
@@ -583,17 +584,18 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const projectPath = normalizePath(project.path);
|
||||
if (!projectPath) continue;
|
||||
try {
|
||||
// Use store-cached isGitRepo when available; fall back to
|
||||
// a direct check for projects the Git store hasn't seen yet.
|
||||
// Forcing `ensureStatus` here also warms the store so the
|
||||
// PR/render paths downstream can read isGitRepo for free.
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
|
||||
if (!isGitRepo) {
|
||||
const worktrees = await runBackgroundNetworkTask(async () => {
|
||||
// Use store-cached isGitRepo when available; fall back to
|
||||
// a direct check for projects the Git store hasn't seen yet.
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
|
||||
if (!isGitRepo) return null;
|
||||
return listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
});
|
||||
if (worktrees === null) {
|
||||
worktreesByProject.delete(projectPath);
|
||||
continue;
|
||||
}
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled) return;
|
||||
if (worktrees.length === 0) {
|
||||
worktreesByProject.delete(projectPath);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { mapWithConcurrency } from '@/lib/concurrency';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
@@ -36,7 +37,7 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
|
||||
// Trigger ensureStatus for each project to populate store
|
||||
normalizedProjects.forEach((project) => {
|
||||
void ensureStatus(project.normalizedPath, git);
|
||||
void runBackgroundNetworkTask(() => ensureStatus(project.normalizedPath, git));
|
||||
});
|
||||
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
|
||||
@@ -129,9 +130,11 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
const entries = await mapWithConcurrency(pending, 2, async (project) => {
|
||||
const inputBranch = gitRepoStatus.get(project.normalizedPath)?.branch?.trim() ?? '';
|
||||
const inputKey = `${project.normalizedPath}\0${inputBranch}`;
|
||||
const branch = await getRootBranch(
|
||||
project.normalizedPath,
|
||||
inputBranch ? { knownBranch: inputBranch } : undefined,
|
||||
const branch = await runBackgroundNetworkTask(() =>
|
||||
getRootBranch(
|
||||
project.normalizedPath,
|
||||
inputBranch ? { knownBranch: inputBranch } : undefined,
|
||||
)
|
||||
).catch(() => null);
|
||||
return { id: project.id, inputKey, branch };
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AnimationHandlers {
|
||||
|
||||
interface UseChatAutoFollowOptions {
|
||||
currentSessionId: string | null;
|
||||
currentSessionKey: string | null;
|
||||
sessionMessageCount: number;
|
||||
sessionIsWorking: boolean;
|
||||
isMobile: boolean;
|
||||
@@ -156,6 +157,7 @@ const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | n
|
||||
|
||||
export const useChatAutoFollow = ({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
@@ -186,8 +188,10 @@ export const useChatAutoFollow = ({
|
||||
sessionMessageCountRef.current = sessionMessageCount;
|
||||
const currentSessionIdRef = React.useRef(currentSessionId);
|
||||
currentSessionIdRef.current = currentSessionId;
|
||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||
currentSessionKeyRef.current = currentSessionKey;
|
||||
|
||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
// Programmatic-scroll marker: the bottom position we last
|
||||
// wrote and when. A scroll event whose scrollTop matches `top` within a few
|
||||
@@ -468,14 +472,14 @@ export const useChatAutoFollow = ({
|
||||
}, [flushSave]);
|
||||
|
||||
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) return false;
|
||||
const sessionKey = currentSessionKeyRef.current;
|
||||
if (!sessionKey) return false;
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
// ChatViewport not mounted yet (e.g., session still hydrating).
|
||||
// Record the request so the container-attach effect can replay it.
|
||||
pendingInitialRestoreRef.current = sessionId;
|
||||
pendingInitialRestoreRef.current = sessionKey;
|
||||
setStateValue('following');
|
||||
return false;
|
||||
}
|
||||
@@ -496,18 +500,18 @@ export const useChatAutoFollow = ({
|
||||
|
||||
// ── session change ───────────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
||||
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
lastSessionKeyRef.current = currentSessionKey;
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
flushSave();
|
||||
autoRef.current = null;
|
||||
// Drop any pending restore request inherited from a different session.
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) {
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
|
||||
pendingInitialRestoreRef.current = null;
|
||||
}
|
||||
}, [currentSessionId, flushSave]);
|
||||
}, [currentSessionId, currentSessionKey, flushSave]);
|
||||
|
||||
// When work begins and we are still
|
||||
// following, pin to the bottom. When work stops, keep following alive for a
|
||||
@@ -547,10 +551,10 @@ export const useChatAutoFollow = ({
|
||||
// preventing a visible flash of content at the wrong scroll position.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!containerEl) return;
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionId) {
|
||||
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
|
||||
void restoreSnapshot();
|
||||
}
|
||||
}, [containerEl, currentSessionId, restoreSnapshot]);
|
||||
}, [containerEl, currentSessionKey, restoreSnapshot]);
|
||||
|
||||
// ── scroll event handling ────────────────────────────────────────────────
|
||||
const handleScrollEvent = React.useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getBackgroundNetworkState, runBackgroundNetworkTask } from "./background-network"
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe("runBackgroundNetworkTask", () => {
|
||||
test("caps concurrent tasks at the limit and drains waiters in order", async () => {
|
||||
const { limit } = getBackgroundNetworkState()
|
||||
const gates = Array.from({ length: limit + 2 }, () => deferred<string>())
|
||||
const started: number[] = []
|
||||
const results = gates.map((gate, index) => runBackgroundNetworkTask(() => {
|
||||
started.push(index)
|
||||
return gate.promise
|
||||
}))
|
||||
|
||||
await Promise.resolve()
|
||||
expect(started).toEqual(Array.from({ length: limit }, (_, index) => index))
|
||||
expect(getBackgroundNetworkState().active).toBe(limit)
|
||||
expect(getBackgroundNetworkState().waiting).toBe(2)
|
||||
|
||||
gates[0].resolve("a")
|
||||
await results[0]
|
||||
expect(started).toContain(limit)
|
||||
|
||||
for (const [index, gate] of gates.entries()) gate.resolve(`v${index}`)
|
||||
expect(await Promise.all(results)).toEqual(["a", ...gates.slice(1).map((_, index) => `v${index + 1}`)])
|
||||
expect(getBackgroundNetworkState().active).toBe(0)
|
||||
expect(getBackgroundNetworkState().waiting).toBe(0)
|
||||
})
|
||||
|
||||
test("releases the slot when a task rejects", async () => {
|
||||
await expect(runBackgroundNetworkTask(() => Promise.reject(new Error("boom")))).rejects.toThrow("boom")
|
||||
expect(getBackgroundNetworkState().active).toBe(0)
|
||||
const value = await runBackgroundNetworkTask(() => Promise.resolve(42))
|
||||
expect(value).toBe(42)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared concurrency gate for background network traffic.
|
||||
*
|
||||
* The browser allows only ~6 concurrent HTTP/1.1 connections per origin, and
|
||||
* every runtime (web, desktop loopback, VS Code, mobile host) funnels API
|
||||
* traffic through one origin. During startup many subsystems fan out at once —
|
||||
* per-directory session/status polls, git checks per project and worktree,
|
||||
* command/skill discovery, global session pages — and several of those calls
|
||||
* are slow while the OpenCode server is still warming up. Uncapped, they
|
||||
* occupy the whole connection pool and interactive traffic (opening a session
|
||||
* and fetching its messages) queues for seconds behind them.
|
||||
*
|
||||
* Every poll/prefetch-shaped background call should run through
|
||||
* {@link runBackgroundNetworkTask} so the aggregate background footprint stays
|
||||
* bounded and sockets remain free for the critical path. GitHub PR status has
|
||||
* its own dedicated gate (see useGitHubPrStatusStore) because a single PR
|
||||
* request can hold a socket for up to 12s and must not starve other
|
||||
* background work either; the two caps combined still leave sockets free.
|
||||
*/
|
||||
|
||||
const BACKGROUND_NETWORK_CONCURRENCY = 3
|
||||
|
||||
let backgroundNetworkActive = 0
|
||||
const backgroundNetworkWaiters: Array<() => void> = []
|
||||
|
||||
const acquireBackgroundNetworkSlot = (): Promise<void> => {
|
||||
if (backgroundNetworkActive < BACKGROUND_NETWORK_CONCURRENCY) {
|
||||
backgroundNetworkActive += 1
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
backgroundNetworkWaiters.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
const releaseBackgroundNetworkSlot = (): void => {
|
||||
const next = backgroundNetworkWaiters.shift()
|
||||
if (next) {
|
||||
// Hand the slot directly to the next waiter — keep the active count steady.
|
||||
next()
|
||||
return
|
||||
}
|
||||
backgroundNetworkActive = Math.max(0, backgroundNetworkActive - 1)
|
||||
}
|
||||
|
||||
/** Run one background network call under the shared concurrency gate. */
|
||||
export const runBackgroundNetworkTask = async <T>(task: () => Promise<T>): Promise<T> => {
|
||||
await acquireBackgroundNetworkSlot()
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
releaseBackgroundNetworkSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only visibility into the gate. */
|
||||
export const getBackgroundNetworkState = () => ({
|
||||
active: backgroundNetworkActive,
|
||||
waiting: backgroundNetworkWaiters.length,
|
||||
limit: BACKGROUND_NETWORK_CONCURRENCY,
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { getGitStatus, gitFetch, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from './gitApiHttp';
|
||||
import {
|
||||
getGitBranches,
|
||||
getGitStatus,
|
||||
gitFetch,
|
||||
stageGitFile,
|
||||
stageGitFiles,
|
||||
unstageGitFile,
|
||||
unstageGitFiles,
|
||||
} from './gitApiHttp';
|
||||
|
||||
type FetchCall = {
|
||||
input: RequestInfo | URL;
|
||||
@@ -160,3 +168,18 @@ describe('gitApiHttp status cache', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitApiHttp request priority', () => {
|
||||
test('leaves low-level reads outside the background policy', async () => {
|
||||
installWindowMock();
|
||||
const calls = installFetchMock();
|
||||
try {
|
||||
await getGitBranches('/repo-interactive');
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].init?.priority).toBe(undefined);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { retry } from "@/sync/retry";
|
||||
import { stripSessionListDetails } from "@/sync/sanitize";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance";
|
||||
|
||||
export type GlobalSessionRecord = Session & {
|
||||
@@ -103,11 +103,9 @@ export async function listGlobalSessionPages(
|
||||
let attempts = 0;
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation,
|
||||
runtimeKey: getRuntimeKey(),
|
||||
directory: options.directory,
|
||||
caller: cursor === undefined ? "initial-page" : "pagination",
|
||||
});
|
||||
const { response, payload } = await retry(
|
||||
const { response, payload } = await runBackgroundNetworkTask(() => retry(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
const response = await apiClient.experimental.session.list({
|
||||
@@ -122,7 +120,7 @@ export async function listGlobalSessionPages(
|
||||
return { response, payload };
|
||||
},
|
||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
||||
).catch((error) => {
|
||||
)).catch((error) => {
|
||||
finishPerformanceEvent("error", { retryCount: Math.max(0, attempts - 1) });
|
||||
throw error;
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/
|
||||
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
||||
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
|
||||
|
||||
export type CommandScope = 'user' | 'project';
|
||||
@@ -169,10 +170,10 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
// Ensure the list is scoped to the same directory we use for config source detection.
|
||||
const commands = await opencodeClient.withDirectory(
|
||||
const commands = await runBackgroundNetworkTask(() => opencodeClient.withDirectory(
|
||||
directory,
|
||||
() => opencodeClient.listCommandsWithDetails()
|
||||
);
|
||||
));
|
||||
|
||||
const configurableCommands = commands.filter((cmd) => cmd.source !== 'skill');
|
||||
const commandsWithScope = await Promise.all(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { useGlobalSessionsStore } from "./useGlobalSessionsStore"
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
@@ -20,16 +23,17 @@ const deferred = <T>(): Deferred<T> => {
|
||||
let activeRequest: Deferred<Session[]>
|
||||
let archivedRequest: Deferred<Session[]>
|
||||
|
||||
mock.module("@/lib/opencode/client", () => ({
|
||||
opencodeClient: { getSdkClient: () => ({}), getDirectory: () => "/source", setDirectory: () => undefined },
|
||||
}))
|
||||
mock.module("@/stores/globalSessions", () => ({
|
||||
listGlobalSessionPages: (_sdk: unknown, options: { archived?: boolean }) => (
|
||||
options.archived ? archivedRequest.promise : activeRequest.promise
|
||||
),
|
||||
}))
|
||||
|
||||
const { useGlobalSessionsStore } = await import("./useGlobalSessionsStore")
|
||||
const sdk = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async (options: { archived?: boolean }) => ({
|
||||
data: await (options.archived ? archivedRequest.promise : activeRequest.promise),
|
||||
response: { headers: new Headers() },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const originalGetSdkClient = opencodeClient.getSdkClient
|
||||
|
||||
const session = (id: string, title = id, archived?: number): Session => ({
|
||||
id,
|
||||
@@ -41,9 +45,14 @@ describe("global session mutation reconciliation", () => {
|
||||
beforeEach(() => {
|
||||
activeRequest = deferred<Session[]>()
|
||||
archivedRequest = deferred<Session[]>()
|
||||
opencodeClient.getSdkClient = () => sdk
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.getSdkClient = originalGetSdkClient
|
||||
})
|
||||
|
||||
test("keeps a session created after a full load starts", async () => {
|
||||
const loading = useGlobalSessionsStore.getState().loadSessions()
|
||||
useGlobalSessionsStore.getState().upsertSession(session("created"))
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/configUpdate";
|
||||
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { runBackgroundNetworkTask } from "@/lib/background-network";
|
||||
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
@@ -221,7 +222,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
try {
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills${queryParams}`);
|
||||
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, { priority: 'low' }));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list skills: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
|
||||
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
// Background check — keep sockets free for interactive traffic at startup.
|
||||
priority: 'low',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -128,6 +128,8 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega
|
||||
|
||||
Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks.
|
||||
|
||||
The active-session watchdog in `sync-context.tsx` (per-directory status polls and child-session discovery lists) runs its network calls through the shared background-network gate in `@/lib/background-network`, alongside poll-shaped git reads, global session pages, and command/skill discovery. Background fan-out must stay under that gate so the browser's per-origin connection pool keeps free sockets for interactive traffic — an uncapped startup burst previously queued the first session-open message fetch for seconds.
|
||||
|
||||
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
|
||||
|
||||
VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned.
|
||||
@@ -164,15 +166,16 @@ Rules:
|
||||
4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion.
|
||||
5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth.
|
||||
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
||||
7. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
||||
|
||||
## Loading diagnostics
|
||||
|
||||
Session loading instrumentation is disabled by default. Set `localStorage.openchamber_session_load_perf` to `"1"`, reproduce the interaction, then inspect `window.__openchamberSessionLoadPerformance.events`.
|
||||
|
||||
The bounded event buffer records bootstrap, message, and global-list operations with queue/duration, caller, outcome, retry count, and record count where applicable. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale.
|
||||
The bounded event buffer records only controlled bootstrap, message, and global-list operation/caller labels with queue/duration, outcome, retry count, and downloaded record count where applicable. Message-page events also record the requested limit and whether a cursor was present. When diagnostics are enabled, the selected chat records its first painted renderable message snapshot once per recent session identity and immediately clears the corresponding browser performance entry after emitting the trace mark. Canceled frames retain no measured identity, so returning to that session can schedule a replacement measurement; completed identity tracking uses the same 1,000-entry ceiling as the event buffer. Exported events never retain runtime keys, directories, session IDs, credentials, or message content. Initial-message expansion counts every downloaded page, not only the accepted page. The browser profiler independently validates the known labels and finite numeric fields before export. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale.
|
||||
|
||||
High-frequency sync diagnostics are separately disabled by default. Set `localStorage.openchamber_sync_perf` to `"1"` before reload to enable fixed numeric counters for pipeline traffic, reducer publications, streaming reconciliations, entries/messages visited, targeted heartbeat work, and persistence serialization/write volume. The hot path performs only a null check while disabled; counters never retain IDs, payloads, or user content.
|
||||
|
||||
|
||||
@@ -525,7 +525,6 @@ export class ChildStoreManager {
|
||||
this.notifyBootstrapSubscribers()
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation: "bootstrap.directory",
|
||||
directory: next.directory,
|
||||
caller: next.reason,
|
||||
queuedMs: Math.max(0, Date.now() - next.enqueuedAt),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
const STORAGE_KEY = "openchamber_session_load_perf"
|
||||
const MAX_EVENTS = 1_000
|
||||
const ALLOWED_OPERATIONS = new Set([
|
||||
"bootstrap.directory",
|
||||
"bootstrap.sessions.all",
|
||||
"bootstrap.sessions.archived",
|
||||
"bootstrap.sessions.roots",
|
||||
"global-sessions.active",
|
||||
"global-sessions.archived",
|
||||
"session-messages.initial",
|
||||
"session-messages.older",
|
||||
"session-messages.page",
|
||||
"session-messages.refresh",
|
||||
"session-messages.visible",
|
||||
"session-prefetch",
|
||||
])
|
||||
const ALLOWED_CALLERS = new Set([
|
||||
"action-demand",
|
||||
"current-directory",
|
||||
"initial",
|
||||
"initial-page",
|
||||
"known-project",
|
||||
"known-worktree",
|
||||
"older",
|
||||
"pagination",
|
||||
"prefetch",
|
||||
"project-expanded",
|
||||
"refresh",
|
||||
"selected-session",
|
||||
"server-connected",
|
||||
"worktree-expanded",
|
||||
])
|
||||
const ALLOWED_OUTCOMES = new Set<SessionLoadPerformanceOutcome>([
|
||||
"complete",
|
||||
"error",
|
||||
"stale",
|
||||
"deduplicated",
|
||||
"canceled",
|
||||
])
|
||||
|
||||
type SessionLoadPerformanceOutcome = "complete" | "error" | "stale" | "deduplicated" | "canceled"
|
||||
|
||||
type SessionLoadPerformanceEvent = {
|
||||
operation: string
|
||||
runtimeKey?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
caller?: string
|
||||
queuedMs?: number
|
||||
requestLimit?: number
|
||||
cursorPresent?: boolean
|
||||
durationMs: number
|
||||
outcome: SessionLoadPerformanceOutcome
|
||||
retryCount?: number
|
||||
@@ -27,7 +63,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = (): boolean => {
|
||||
const isSessionLoadPerformanceEnabled = (): boolean => {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
||||
@@ -40,23 +76,100 @@ const now = (): number => typeof performance !== "undefined" && typeof performan
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
|
||||
const nonNegativeNumber = (value: unknown): number | undefined => (
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
)
|
||||
const nonNegativeInteger = (value: unknown): number | undefined => (
|
||||
Number.isInteger(value) && Number(value) >= 0 ? Number(value) : undefined
|
||||
)
|
||||
|
||||
export function startSessionLoadPerformanceEvent(input: Omit<SessionLoadPerformanceEvent, "at" | "durationMs" | "outcome">) {
|
||||
if (!enabled()) return () => undefined
|
||||
if (
|
||||
!isSessionLoadPerformanceEnabled()
|
||||
|| !ALLOWED_OPERATIONS.has(input.operation)
|
||||
|| (input.caller !== undefined && !ALLOWED_CALLERS.has(input.caller))
|
||||
) return () => undefined
|
||||
const startedAt = now()
|
||||
return (
|
||||
outcome: SessionLoadPerformanceOutcome,
|
||||
details?: Partial<Pick<SessionLoadPerformanceEvent, "retryCount" | "recordCount">>,
|
||||
) => {
|
||||
if (typeof window === "undefined") return
|
||||
if (typeof window === "undefined" || !ALLOWED_OUTCOMES.has(outcome)) return
|
||||
const state = window.__openchamberSessionLoadPerformance ?? { events: [] }
|
||||
const queuedMs = nonNegativeNumber(input.queuedMs)
|
||||
const requestLimit = nonNegativeInteger(input.requestLimit)
|
||||
const retryCount = nonNegativeInteger(details?.retryCount ?? input.retryCount)
|
||||
const recordCount = nonNegativeInteger(details?.recordCount ?? input.recordCount)
|
||||
state.events.push({
|
||||
...input,
|
||||
...details,
|
||||
operation: input.operation,
|
||||
...(input.caller !== undefined ? { caller: input.caller } : {}),
|
||||
...(queuedMs !== undefined ? { queuedMs } : {}),
|
||||
...(requestLimit !== undefined ? { requestLimit } : {}),
|
||||
...(typeof input.cursorPresent === "boolean" ? { cursorPresent: input.cursorPresent } : {}),
|
||||
outcome,
|
||||
durationMs: Math.max(0, now() - startedAt),
|
||||
...(retryCount !== undefined ? { retryCount } : {}),
|
||||
...(recordCount !== undefined ? { recordCount } : {}),
|
||||
at: Date.now(),
|
||||
})
|
||||
if (state.events.length > MAX_EVENTS) state.events.splice(0, state.events.length - MAX_EVENTS)
|
||||
window.__openchamberSessionLoadPerformance = state
|
||||
}
|
||||
}
|
||||
|
||||
type FirstVisibleSessionPerformanceDependencies = {
|
||||
enabled: () => boolean
|
||||
requestFrame: (callback: FrameRequestCallback) => number
|
||||
cancelFrame: (frame: number) => void
|
||||
markVisible: () => void
|
||||
startEvent: typeof startSessionLoadPerformanceEvent
|
||||
}
|
||||
|
||||
const FIRST_VISIBLE_MARK = "openchamber.chat.first_message_visible"
|
||||
|
||||
export function createFirstVisibleSessionPerformanceTracker(
|
||||
dependencies?: Partial<FirstVisibleSessionPerformanceDependencies>,
|
||||
) {
|
||||
const enabled = dependencies?.enabled ?? isSessionLoadPerformanceEnabled
|
||||
const requestFrame = dependencies?.requestFrame ?? ((callback) => window.requestAnimationFrame(callback))
|
||||
const cancelFrame = dependencies?.cancelFrame ?? ((frame) => window.cancelAnimationFrame(frame))
|
||||
const markVisible = dependencies?.markVisible ?? (() => {
|
||||
performance.mark(FIRST_VISIBLE_MARK)
|
||||
performance.clearMarks(FIRST_VISIBLE_MARK)
|
||||
})
|
||||
const startEvent = dependencies?.startEvent ?? startSessionLoadPerformanceEvent
|
||||
const measuredKeys = new Set<string>()
|
||||
let pending: { key: string; frame: number } | null = null
|
||||
|
||||
return {
|
||||
schedule(key: string, recordCount: number): () => void {
|
||||
if (!enabled() || measuredKeys.has(key)) return () => undefined
|
||||
if (pending) {
|
||||
cancelFrame(pending.frame)
|
||||
pending = null
|
||||
}
|
||||
const finishPerformanceEvent = startEvent({
|
||||
operation: "session-messages.visible",
|
||||
caller: "selected-session",
|
||||
recordCount,
|
||||
})
|
||||
const frame = requestFrame(() => {
|
||||
if (pending?.key !== key || pending.frame !== frame) return
|
||||
pending = null
|
||||
measuredKeys.add(key)
|
||||
if (measuredKeys.size > MAX_EVENTS) {
|
||||
measuredKeys.delete(measuredKeys.values().next().value!)
|
||||
}
|
||||
markVisible()
|
||||
finishPerformanceEvent("complete")
|
||||
})
|
||||
pending = { key, frame }
|
||||
|
||||
return () => {
|
||||
if (pending?.key !== key || pending.frame !== frame) return
|
||||
cancelFrame(frame)
|
||||
pending = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { ChildStoreManager } from "./child-store"
|
||||
import { SessionMessageLoader } from "./session-message-loader"
|
||||
import {
|
||||
createFirstVisibleSessionPerformanceTracker,
|
||||
startSessionLoadPerformanceEvent,
|
||||
} from "./session-load-performance"
|
||||
|
||||
const createRecord = (sessionID: string, id = "msg_1") => ({
|
||||
info: { id, sessionID, role: "user", time: { created: 1 } } as Message,
|
||||
@@ -56,6 +60,34 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("leaves older history loading to explicit viewport demand", async () => {
|
||||
const calls: Array<{ limit?: number; before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
|
||||
calls.push({ limit, before })
|
||||
return before
|
||||
? response([createRecord(sessionID, "msg_older")])
|
||||
: response([createRecord(sessionID, "msg_latest")], "older-cursor")
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await loader.ensure(target, { reason: "prefetch" })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).toEqual([{ limit: 50, before: undefined }])
|
||||
expect(loader.getSnapshot(target).cursor).toBe("older-cursor")
|
||||
|
||||
await loader.loadOlder(target)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ limit: 50, before: undefined },
|
||||
{ limit: 100, before: "older-cursor" },
|
||||
])
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||
.toEqual(["msg_latest", "msg_older"].sort())
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("runs a requested tail refresh after an older in-flight load", async () => {
|
||||
const initial = deferred<ReturnType<typeof response>>()
|
||||
const refresh = deferred<ReturnType<typeof response>>()
|
||||
@@ -128,6 +160,34 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("loads older history with the selected directory's cursor for duplicate session IDs", async () => {
|
||||
const providerDirectory = "/repo/provider"
|
||||
const selectedDirectory = "/repo/selected-worktree"
|
||||
const sessionID = "shared"
|
||||
const calls: Array<{ directory?: string; before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ directory, before }) => {
|
||||
calls.push({ directory, before })
|
||||
return before
|
||||
? response([createRecord(sessionID, `older-${directory}`)])
|
||||
: response([createRecord(sessionID, `latest-${directory}`)], `${directory}-cursor`)
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
loader.ensure({ directory: providerDirectory, sessionID }),
|
||||
loader.ensure({ directory: selectedDirectory, sessionID }),
|
||||
])
|
||||
calls.length = 0
|
||||
|
||||
await loader.loadOlder({ directory: selectedDirectory, sessionID })
|
||||
|
||||
expect(calls).toEqual([{
|
||||
directory: selectedDirectory,
|
||||
before: `${selectedDirectory}-cursor`,
|
||||
}])
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("exposes a retryable error without clearing an existing snapshot", async () => {
|
||||
let fail = true
|
||||
const { childStores, loader } = createLoader(async ({ sessionID }) => {
|
||||
@@ -209,4 +269,165 @@ describe("SessionMessageLoader", () => {
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("reports retries and every downloaded initial expansion record", async () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window")
|
||||
const diagnosticWindow = {
|
||||
location: { search: "" },
|
||||
localStorage: {
|
||||
getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null,
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow })
|
||||
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return {}
|
||||
if (calls === 2) {
|
||||
const assistant = createRecord(target.sessionID, "msg_assistant")
|
||||
assistant.info = { ...assistant.info, role: "assistant" } as Message
|
||||
return response([assistant], "older")
|
||||
}
|
||||
return response([createRecord(target.sessionID, "msg_user")])
|
||||
})
|
||||
|
||||
try {
|
||||
await loader.ensure(target)
|
||||
|
||||
const events = diagnosticWindow.__openchamberSessionLoadPerformance?.events ?? []
|
||||
const initialEvent = events.find((event) => event.operation === "session-messages.initial")
|
||||
const pageEvents = events.filter((event) => event.operation === "session-messages.page")
|
||||
expect(calls).toBe(3)
|
||||
expect(pageEvents.map((event) => event.requestLimit)).toEqual([50, 100])
|
||||
expect(pageEvents.map((event) => event.cursorPresent)).toEqual([false, false])
|
||||
expect(pageEvents.map((event) => event.recordCount)).toEqual([1, 1])
|
||||
expect(initialEvent?.outcome).toBe("complete")
|
||||
expect(initialEvent?.retryCount).toBe(1)
|
||||
expect(initialEvent?.recordCount).toBe(2)
|
||||
expect("runtimeKey" in initialEvent!).toBe(false)
|
||||
expect("directory" in initialEvent!).toBe(false)
|
||||
expect("sessionID" in initialEvent!).toBe(false)
|
||||
} finally {
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow)
|
||||
else Reflect.deleteProperty(globalThis, "window")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("session load performance diagnostics", () => {
|
||||
test("rejects unknown raw labels and preserves approved input counts", () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window")
|
||||
const diagnosticWindow = {
|
||||
localStorage: {
|
||||
getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null,
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow })
|
||||
|
||||
try {
|
||||
const finishUnknown = startSessionLoadPerformanceEvent({
|
||||
operation: "secret-operation",
|
||||
caller: "secret-caller",
|
||||
recordCount: 999,
|
||||
})
|
||||
finishUnknown("complete")
|
||||
const finishVisible = startSessionLoadPerformanceEvent({
|
||||
operation: "session-messages.visible",
|
||||
caller: "selected-session",
|
||||
recordCount: 30,
|
||||
})
|
||||
finishVisible("complete")
|
||||
|
||||
expect(diagnosticWindow.__openchamberSessionLoadPerformance?.events).toHaveLength(1)
|
||||
const event = diagnosticWindow.__openchamberSessionLoadPerformance?.events[0]
|
||||
expect(event?.operation).toBe("session-messages.visible")
|
||||
expect(event?.caller).toBe("selected-session")
|
||||
expect(event?.recordCount).toBe(30)
|
||||
expect(JSON.stringify(diagnosticWindow.__openchamberSessionLoadPerformance)).not.toContain("secret")
|
||||
} finally {
|
||||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow)
|
||||
else Reflect.deleteProperty(globalThis, "window")
|
||||
}
|
||||
})
|
||||
|
||||
test("does not schedule visibility work while diagnostics are disabled", () => {
|
||||
let requestedFrames = 0
|
||||
let visibleMarks = 0
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => false,
|
||||
requestFrame: () => {
|
||||
requestedFrames += 1
|
||||
return 1
|
||||
},
|
||||
cancelFrame: () => undefined,
|
||||
markVisible: () => {
|
||||
visibleMarks += 1
|
||||
},
|
||||
})
|
||||
|
||||
tracker.schedule("session-a", 10)
|
||||
|
||||
expect(requestedFrames).toBe(0)
|
||||
expect(visibleMarks).toBe(0)
|
||||
})
|
||||
|
||||
test("reschedules an identity when its pending visibility frame was canceled", () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const marks: string[] = []
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => true,
|
||||
requestFrame: (callback) => {
|
||||
nextFrame += 1
|
||||
frames.set(nextFrame, callback)
|
||||
return nextFrame
|
||||
},
|
||||
cancelFrame: (frame) => {
|
||||
frames.delete(frame)
|
||||
},
|
||||
markVisible: () => marks.push("visible"),
|
||||
startEvent: () => () => undefined,
|
||||
})
|
||||
|
||||
const cancelFirstA = tracker.schedule("session-a", 10)
|
||||
cancelFirstA()
|
||||
const cancelB = tracker.schedule("session-b", 10)
|
||||
cancelB()
|
||||
tracker.schedule("session-a", 10)
|
||||
frames.get(3)?.(0)
|
||||
|
||||
expect(marks).toEqual(["visible"])
|
||||
})
|
||||
|
||||
test("does not remeasure a completed identity after another session", () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const marks: string[] = []
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => true,
|
||||
requestFrame: (callback) => {
|
||||
nextFrame += 1
|
||||
frames.set(nextFrame, callback)
|
||||
return nextFrame
|
||||
},
|
||||
cancelFrame: (frame) => {
|
||||
frames.delete(frame)
|
||||
},
|
||||
markVisible: () => marks.push("visible"),
|
||||
startEvent: () => () => undefined,
|
||||
})
|
||||
|
||||
tracker.schedule("session-a", 10)
|
||||
frames.get(1)?.(0)
|
||||
tracker.schedule("session-b", 10)
|
||||
frames.get(2)?.(0)
|
||||
tracker.schedule("session-a", 10)
|
||||
|
||||
expect(nextFrame).toBe(2)
|
||||
expect(marks).toEqual(["visible", "visible"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,11 @@ type FetchedPage = {
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
type LoadPerformanceDetails = {
|
||||
retryCount: number
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
type LoaderConfiguration = {
|
||||
sdk: OpencodeClient
|
||||
runtimeKey: string
|
||||
@@ -201,15 +206,8 @@ export class SessionMessageLoader {
|
||||
}
|
||||
if (options?.force) this.bumpGeneration(entry)
|
||||
const kind: SessionMessageLoadKind = options?.reason === "prefetch" ? "prefetch" : "initial"
|
||||
return this.startLoad(normalized, entry, store, kind, async (isCurrent) => {
|
||||
await this.loadInitial(normalized, entry, store, isCurrent)
|
||||
if (!isMobileSurfaceRuntime() && isCurrent()) {
|
||||
queueMicrotask(() => {
|
||||
if (isCurrent() && entry.snapshot.cursor && !entry.snapshot.complete) {
|
||||
void this.loadOlder(normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.startLoad(normalized, entry, store, kind, async (isCurrent, performance) => {
|
||||
await this.loadInitial(normalized, entry, store, isCurrent, performance)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,8 +223,8 @@ export class SessionMessageLoader {
|
||||
if (entry.snapshot.complete || !entry.snapshot.cursor) return Promise.resolve()
|
||||
const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false })
|
||||
const cursor = entry.snapshot.cursor
|
||||
return this.startLoad(normalized, entry, store, "older", async (isCurrent) => {
|
||||
const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor)
|
||||
return this.startLoad(normalized, entry, store, "older", async (isCurrent, performance) => {
|
||||
const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor, "older", performance)
|
||||
if (!isCurrent()) return
|
||||
const committed = this.commitPage(normalized, entry, store, page, "prepend", isCurrent)
|
||||
if (!committed || !isCurrent()) return
|
||||
@@ -279,11 +277,11 @@ export class SessionMessageLoader {
|
||||
}
|
||||
const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false })
|
||||
this.bumpGeneration(entry)
|
||||
return this.startLoad(normalized, entry, store, "refresh", async (isCurrent) => {
|
||||
return this.startLoad(normalized, entry, store, "refresh", async (isCurrent, performance) => {
|
||||
const previousCoverage = entry.snapshot.resolved
|
||||
? { cursor: entry.snapshot.cursor, complete: entry.snapshot.complete }
|
||||
: null
|
||||
const page = await this.fetchPage(normalized, Math.max(1, limit))
|
||||
const page = await this.fetchPage(normalized, Math.max(1, limit), undefined, "refresh", performance)
|
||||
if (!isCurrent()) return
|
||||
const committed = this.commitPage(normalized, entry, store, page, "merge", isCurrent)
|
||||
if (!committed || !isCurrent()) return
|
||||
@@ -455,15 +453,12 @@ export class SessionMessageLoader {
|
||||
entry: LoaderEntry,
|
||||
store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter },
|
||||
kind: SessionMessageLoadKind,
|
||||
run: (isCurrent: () => boolean) => Promise<void>,
|
||||
run: (isCurrent: () => boolean, performance: LoadPerformanceDetails) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const generation = entry.snapshot.generation
|
||||
const sdkEpoch = this.sdkEpoch
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation: kind === "prefetch" ? "session-prefetch" : `session-messages.${kind}`,
|
||||
runtimeKey: this.runtimeKey,
|
||||
directory: target.directory,
|
||||
sessionID: target.sessionID,
|
||||
caller: kind,
|
||||
})
|
||||
const isCurrent = () => (
|
||||
@@ -472,21 +467,22 @@ export class SessionMessageLoader {
|
||||
&& entry.snapshot.generation === generation
|
||||
&& this.childStores.getChild(target.directory) === store
|
||||
)
|
||||
const performance = { retryCount: 0, recordCount: 0 }
|
||||
this.patchEntry(entry, { status: "loading", loadingKind: kind, error: null })
|
||||
let loadPromise: Promise<void>
|
||||
try {
|
||||
loadPromise = run(isCurrent)
|
||||
loadPromise = run(isCurrent, performance)
|
||||
} catch (error) {
|
||||
loadPromise = Promise.reject(error)
|
||||
}
|
||||
const promise = loadPromise
|
||||
.then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale"))
|
||||
.then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale", performance))
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrent()) {
|
||||
finishPerformanceEvent("stale")
|
||||
finishPerformanceEvent("stale", performance)
|
||||
return
|
||||
}
|
||||
finishPerformanceEvent("error")
|
||||
finishPerformanceEvent("error", performance)
|
||||
this.patchEntry(entry, {
|
||||
status: "error",
|
||||
loadingKind: null,
|
||||
@@ -505,10 +501,11 @@ export class SessionMessageLoader {
|
||||
entry: LoaderEntry,
|
||||
store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter },
|
||||
isCurrent: () => boolean,
|
||||
performance?: LoadPerformanceDetails,
|
||||
): Promise<void> {
|
||||
const storeMessageCount = store.getState().message[target.sessionID]?.length ?? 0
|
||||
const firstLimit = Math.max(entry.snapshot.limit, storeMessageCount, getInitialPageSize())
|
||||
const firstPage = await this.fetchPage(target, firstLimit)
|
||||
const firstPage = await this.fetchPage(target, firstLimit, undefined, "initial-page", performance)
|
||||
if (!isCurrent()) return
|
||||
const deferFirstCommit = !firstPage.complete && !hasUserMessage(firstPage.session)
|
||||
let committed = deferFirstCommit
|
||||
@@ -519,7 +516,7 @@ export class SessionMessageLoader {
|
||||
if (deferFirstCommit) {
|
||||
for (const limit of getInitialExpansionLimits()) {
|
||||
if (limit <= firstLimit || !isCurrent()) continue
|
||||
const expandedPage = await this.fetchPage(target, limit)
|
||||
const expandedPage = await this.fetchPage(target, limit, undefined, "initial-page", performance)
|
||||
if (!isCurrent()) return
|
||||
acceptedPage = expandedPage
|
||||
const boundaryFound = hasUserMessage(expandedPage.session)
|
||||
@@ -547,32 +544,58 @@ export class SessionMessageLoader {
|
||||
this.persistCoverage(target, entry.snapshot)
|
||||
}
|
||||
|
||||
private async fetchPage(target: SessionMessageTarget, limit: number, before?: string): Promise<FetchedPage> {
|
||||
const result = await retry(async () => {
|
||||
const response = await this.sdk.session.messages({
|
||||
sessionID: target.sessionID,
|
||||
directory: target.directory,
|
||||
limit,
|
||||
before,
|
||||
})
|
||||
assertSdkSuccess(response, "session.messages")
|
||||
if (!Array.isArray(response.data)) {
|
||||
const error = new Error("session.messages returned no data") as Error & { status?: number }
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
return { data: response.data, response: response.response }
|
||||
private async fetchPage(
|
||||
target: SessionMessageTarget,
|
||||
limit: number,
|
||||
before?: string,
|
||||
caller: "initial-page" | "older" | "refresh" = "initial-page",
|
||||
performance?: LoadPerformanceDetails,
|
||||
): Promise<FetchedPage> {
|
||||
const finishPagePerformance = startSessionLoadPerformanceEvent({
|
||||
operation: "session-messages.page",
|
||||
caller,
|
||||
requestLimit: limit,
|
||||
cursorPresent: before !== undefined,
|
||||
})
|
||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||
const session = records
|
||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
||||
const partsByMessageID = new Map<string, Part[]>()
|
||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
||||
let attempts = 0
|
||||
let recordCount = 0
|
||||
try {
|
||||
const result = await retry(async () => {
|
||||
attempts += 1
|
||||
const response = await this.sdk.session.messages({
|
||||
sessionID: target.sessionID,
|
||||
directory: target.directory,
|
||||
limit,
|
||||
before,
|
||||
})
|
||||
assertSdkSuccess(response, "session.messages")
|
||||
const data = response.data
|
||||
if (!Array.isArray(data)) {
|
||||
const error = new Error("session.messages returned no data") as Error & { status?: number }
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
return { data, response: response.response }
|
||||
})
|
||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||
recordCount = records.length
|
||||
if (performance) performance.recordCount += recordCount
|
||||
const session = records
|
||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
||||
const partsByMessageID = new Map<string, Part[]>()
|
||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
||||
}
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||
return { session, partsByMessageID, cursor, complete: !cursor }
|
||||
} catch (error) {
|
||||
finishPagePerformance("error", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||
throw error
|
||||
} finally {
|
||||
if (performance) performance.retryCount += Math.max(0, attempts - 1)
|
||||
}
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
return { session, partsByMessageID, cursor, complete: !cursor }
|
||||
}
|
||||
|
||||
private commitPage(
|
||||
|
||||
@@ -31,6 +31,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
|
||||
import { retry } from "./retry"
|
||||
import { touchStreamingSession, updateChangedStreamingSessions, updateStreamingState } from "./streaming"
|
||||
import { countSyncPerformance } from "./performance-diagnostics"
|
||||
import { runBackgroundNetworkTask } from "@/lib/background-network"
|
||||
import { setActionRefs } from "./session-actions"
|
||||
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
@@ -242,6 +243,16 @@ const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000
|
||||
const ACTIVE_SESSION_STALE_EVENT_MS = 20_000
|
||||
const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000
|
||||
const CHILD_SESSION_DISCOVERY_INTERVAL_MS = 15_000
|
||||
|
||||
// Active-session watchdog network calls run under the shared
|
||||
// background-network gate (see lib/background-network.ts). The watchdog walks
|
||||
// every initialized child store each tick and fires a status poll plus a
|
||||
// child-session discovery list per directory with active candidates — on
|
||||
// startup with many cache-hydrated directories that is dozens of simultaneous
|
||||
// requests, which would otherwise queue interactive traffic (opening a
|
||||
// session) behind them on the browser's ~6 sockets per origin. Later ticks
|
||||
// still cover every directory via the per-directory timestamps.
|
||||
|
||||
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||
if (!items || items.length === 0) return ""
|
||||
return items
|
||||
@@ -2072,7 +2083,7 @@ export function SyncProvider(props: {
|
||||
if (parentSessionIds.length === 0) return
|
||||
try {
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||
const result = await scopedClient.session.list({ directory, limit: 200 })
|
||||
const result: unknown = await runBackgroundNetworkTask(() => scopedClient.session.list({ directory, limit: 200 }))
|
||||
const allSessions = ((result as { data?: unknown }).data ?? []) as Session[]
|
||||
const state = store.getState()
|
||||
const existingIds = new Set(state.session.map((s) => s.id))
|
||||
@@ -2121,7 +2132,7 @@ export function SyncProvider(props: {
|
||||
polling.add(directory)
|
||||
try {
|
||||
const before = store.getState()
|
||||
const statuses = await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic")
|
||||
const statuses = await runBackgroundNetworkTask(() => resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic"))
|
||||
if (!statuses) return
|
||||
const needsSnapshot = candidateSessionIds.some((sessionId) => (
|
||||
needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId])
|
||||
|
||||
@@ -313,12 +313,11 @@ export function useSync() {
|
||||
|
||||
// Load more (pagination)
|
||||
const loadMore = useCallback(
|
||||
async (sessionID: string, directoryOverride?: string) => {
|
||||
const targetDirectory = directoryOverride || directory
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
touch(sessionID, targetDirectory)
|
||||
await messageLoader.loadOlder({ directory: targetDirectory, sessionID })
|
||||
},
|
||||
[directory, messageLoader, touch],
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const prefetchSession = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user