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:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-07-31 12:51:15 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 09f0c64839
commit aae889b904
41 changed files with 1690 additions and 203 deletions
@@ -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 };
});