perf(ui): improve VS Code chat session switching

Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed.

Session history loading and pagination:

- Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview.

- Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time.

- Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat.

- Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits.

- Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back.

- Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state.

- Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache.

VS Code cache and memory pressure reductions:

- Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run.

- Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session.

- Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation.

- Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work.

- Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared.

- Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages.

- Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size.

Chat render-path reductions:

- Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription.

- Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders.

- Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders.

- Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates.

- Remount the chat viewport when the current session changes, isolating per-session viewport and list state.

- Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history.

VS Code layout and header improvements:

- Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence.

- Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed.

- Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice.

- Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests.

Markdown and file-reference safeguards:

- Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web.

- Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages.

- Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks.

- Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes.

Assistant-message action and preview reductions:

- Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable.

- Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces.

- Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces.

- Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list.

Tool and task rendering optimizations:

- Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present.

- Avoid polling or final-fetching task child sessions once a final metadata summary is available.

- Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches.

- Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays.

- Count write-tool lines by scanning content instead of allocating a split array for large files.

- Avoid trimming large patch strings just to test whether they contain content.

- Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render.

VS Code bridge improvements:

- Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses.

- Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract.

- Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body.

Validation:

- bun run type-check

- bun run lint

- bun run vscode:build
This commit is contained in:
Bohdan Triapitsyn
2026-05-21 15:45:44 +03:00
parent 80cf598d9f
commit 51c8d52ab5
19 changed files with 728 additions and 270 deletions
@@ -35,9 +35,11 @@ import {
useSessionMessageRecords,
useSessions,
useDirectorySync,
useSyncDirectory,
useSessionStatus,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
@@ -342,6 +344,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// Sync actions
const sync = useSync();
const syncDirectory = useSyncDirectory();
const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
[sync],
@@ -384,12 +387,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore(
React.useCallback(
(notify) => currentSessionId
? subscribeSessionPrefetch(syncDirectory, currentSessionId, notify)
: () => undefined,
[currentSessionId, syncDirectory],
),
React.useCallback(
() => currentSessionId ? getSessionPrefetch(syncDirectory, currentSessionId) : undefined,
[currentSessionId, syncDirectory],
),
React.useCallback(() => undefined, []),
);
// Sessions from sync system
const sessions = useSessions();
// Plan detection - watches messages for plan creation and signals store
usePlanDetection(currentSessionId ?? '');
usePlanDetection(currentSessionId ?? '', sessionMessages);
// Session status from sync system
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS;
@@ -494,12 +510,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// History metadata — use sync's hasMore/isLoading
const historyMeta = React.useMemo(() => {
if (!currentSessionId) return null;
const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true;
return {
limit: sessionMessages.length,
complete: !sync.hasMore(currentSessionId),
complete: !(sync.hasMore(currentSessionId) || prefetchHasMore),
loading: sync.isLoading(currentSessionId),
};
}, [currentSessionId, sessionMessages.length, sync]);
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
@@ -868,6 +885,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
key={currentSessionId}
currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
@@ -27,6 +27,7 @@ import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
import { isVSCodeRuntime } from '@/lib/desktop';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -714,6 +715,8 @@ const normalizeCodeBlockText = (code: string, language: string): string => {
};
const CODE_HIGHLIGHT_SETTLE_MS = 300;
const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
const CODE_SHARED_STYLE: React.CSSProperties = {
margin: 0,
background: 'transparent',
@@ -722,6 +725,23 @@ const CODE_SHARED_STYLE: React.CSSProperties = {
lineHeight: 'var(--markdown-code-block-line-height)',
};
const exceedsLineLimit = (value: string, limit: number): boolean => {
let lineCount = 1;
for (let index = 0; index < value.length; index += 1) {
if (value.charCodeAt(index) === 10) {
lineCount += 1;
if (lineCount > limit) {
return true;
}
}
}
return false;
};
const getCodeHighlightLineLimit = (): number => (
isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT
);
const downloadTextFile = (content: string, filename: string, mimeType: string) => {
if (typeof window === 'undefined') {
return;
@@ -753,6 +773,7 @@ const MarkdownCodeBlock: React.FC<{
const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo();
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
const canPreview = language === 'html' || language === 'htm';
@@ -852,7 +873,7 @@ const MarkdownCodeBlock: React.FC<{
</div>
) : (
<div className="px-3 py-2.5">
{highlight ? (
{highlight && !skipHighlight ? (
<SyntaxHighlighter
language={language}
style={syntaxTheme}
@@ -1023,10 +1044,22 @@ interface MarkdownRendererProps {
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 200;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
);
type ParsedFileReference = {
path: string;
line?: number;
@@ -1300,6 +1333,8 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
@@ -1326,6 +1361,14 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
@@ -1362,6 +1405,7 @@ const useFileReferenceInteractions = ({
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
const clearFileLinkAttributes = (candidate: HTMLElement) => {
candidate.removeAttribute('data-openchamber-file-link');
@@ -1376,18 +1420,37 @@ const useFileReferenceInteractions = ({
}
};
const clearAnnotatedFileLinks = () => {
const annotated = container.querySelectorAll<HTMLElement>(FILE_LINK_SELECTOR);
for (const candidate of Array.from(annotated)) {
clearFileLinkAttributes(candidate);
}
};
if (!enabled) {
clearAnnotatedFileLinks();
return;
}
const annotateFileLinks = () => {
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
let linkedCount = 0;
for (const candidate of Array.from(candidates)) {
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate);
if (!enabled || !resolved) {
if (!resolved) {
continue;
}
if (linkedCount >= fileReferenceLinkLimit) {
continue;
}
linkedCount += 1;
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
return;
@@ -984,7 +984,7 @@ const StaticHistoryList: React.FC<{
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
: 0;
if (!shouldVirtualize || (virtualRows.length === 0 && entries.length > 0)) {
if (!shouldVirtualize) {
return (
<div ref={contentRef} className="relative w-full">
{entries.map((entry) => (
@@ -999,6 +999,27 @@ const StaticHistoryList: React.FC<{
);
}
if (virtualRows.length === 0 && entries.length > 0) {
const fallbackStart = Math.max(0, entries.length - MESSAGE_LIST_OVERSCAN * 2);
const fallbackEntries = entries.slice(fallbackStart);
const fallbackHeight = fallbackEntries.reduce((total, entry) => total + estimateHistoryEntryHeight(entry), 0);
const fallbackPaddingTop = Math.max(0, totalSize - fallbackHeight);
return (
<div ref={contentRef} className="relative w-full">
{fallbackPaddingTop > 0 ? <div aria-hidden="true" style={{ height: `${fallbackPaddingTop}px` }} /> : null}
{fallbackEntries.map((entry) => (
<div
key={entry.key}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
))}
</div>
);
}
return (
<div ref={contentRef} className="relative w-full">
{paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null}
@@ -13,6 +13,7 @@ import {
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
import { isVSCodeRuntime } from '@/lib/desktop';
type ViewportAnchor = { messageId: string; offsetTop: number };
@@ -60,7 +61,29 @@ export interface UseChatTimelineControllerResult {
}
const TURN_MODEL_CACHE_MAX = 30
const VSCODE_TURN_MODEL_CACHE_MAX = 4
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
const getTurnModelCacheMax = () => isVSCodeRuntime() ? VSCODE_TURN_MODEL_CACHE_MAX : TURN_MODEL_CACHE_MAX
const shouldCacheTurnModelMessages = (messages: ChatMessageEntry[]): boolean => {
if (!isVSCodeRuntime()) return true
return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES
}
const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; model: TurnWindowModel }) => {
turnModelCache.delete(key)
if (!shouldCacheTurnModelMessages(value.messages)) {
return
}
const max = getTurnModelCacheMax()
while (turnModelCache.size >= max) {
const oldest = turnModelCache.keys().next().value
if (typeof oldest !== 'string') break
turnModelCache.delete(oldest)
}
turnModelCache.set(key, value)
}
export const useChatTimelineController = ({
sessionId,
@@ -80,6 +103,7 @@ export const useChatTimelineController = ({
const key = sessionId ?? ""
const cached = key ? turnModelCache.get(key) : undefined
if (cached && cached.messages === messages) {
rememberTurnModel(key, cached)
previousTurnWindowModelRef.current = cached.model
previousMessagesRef.current = messages
return cached.model
@@ -95,12 +119,7 @@ export const useChatTimelineController = ({
previousMessagesRef.current = messages;
if (key && messages.length > 0) {
// LRU-like eviction: delete oldest when at capacity
if (turnModelCache.size >= TURN_MODEL_CACHE_MAX) {
const oldest = turnModelCache.keys().next().value
if (oldest !== undefined) turnModelCache.delete(oldest)
}
turnModelCache.set(key, { messages, model: nextModel })
rememberTurnModel(key, { messages, model: nextModel })
}
return nextModel;
@@ -42,7 +42,6 @@ import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
import { extractLoopbackUrls } from '@/lib/url';
import { useDeviceInfo } from '@/lib/device';
@@ -986,8 +985,16 @@ const AssistantMessageBody = React.memo(({
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const isVSCode = isVSCodeRuntime();
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const canUseProjectPlanActions = !isVSCode && !isMiniChatSurface && !isMobile;
const canShowMultiRunAction = !isVSCode && !isMiniChatSurface && !isMobile;
const messagePreviewUrl = React.useMemo(() => {
if (isVSCode || isMobile || isMiniChatSurface) {
return null;
}
for (const part of assistantTextParts) {
const text = (part as { text?: unknown }).text;
if (typeof text !== 'string' || text.length === 0) {
@@ -1013,14 +1020,14 @@ const AssistantMessageBody = React.memo(({
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
}
return null;
}, [assistantTextParts, toolParts]);
}, [assistantTextParts, isMobile, isMiniChatSurface, isVSCode, toolParts]);
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getDirectoryForSession = useSessionUIStore((state) => state.getDirectoryForSession);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects);
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -1028,25 +1035,22 @@ const AssistantMessageBody = React.memo(({
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const isSortedRenderMode = chatRenderMode === 'sorted';
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop';
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId) ?? null;
}, [currentSessionId, sessions]);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentProjectRef = React.useMemo(() => {
if (!canUseProjectPlanActions) {
return null;
}
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null)
?? '';
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]);
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
const hasTools = toolParts.length > 0;
@@ -1743,7 +1747,6 @@ const AssistantMessageBody = React.memo(({
}, [messageCompletedAt, messageCreatedAt]);
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const isVSCode = isVSCodeRuntime();
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
const finalTurnActionButtons = (
@@ -1760,7 +1763,7 @@ const AssistantMessageBody = React.memo(({
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : null);
?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null);
if (!directory) {
return;
}
@@ -1773,7 +1776,7 @@ const AssistantMessageBody = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
</Tooltip>
) : null}
{!isMiniChatSurface && !isVSCode ? (
{canUseProjectPlanActions ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1809,7 +1812,7 @@ const AssistantMessageBody = React.memo(({
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip> : null}
{!isMiniChatSurface && !isVSCode ? (
{canShowMultiRunAction ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1840,14 +1843,16 @@ const AssistantMessageBody = React.memo(({
style={CONTAIN_LAYOUT_STYLE}
>
<TextSelectionMenu containerRef={messageContentRef} />
<SaveProjectPlanDialog
open={isPlanDialogOpen}
onOpenChange={setIsPlanDialogOpen}
initialTitle={suggestedPlanTitle}
sourceText={assistantPlanText}
saving={isSavingPlan}
onSave={handleConfirmSaveAsPlan}
/>
{canUseProjectPlanActions ? (
<SaveProjectPlanDialog
open={isPlanDialogOpen}
onOpenChange={setIsPlanDialogOpen}
initialTitle={suggestedPlanTitle}
sourceText={assistantPlanText}
saving={isSavingPlan}
onSave={handleConfirmSaveAsPlan}
/>
) : null}
<div>
<div
className="message-content-text leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
@@ -17,6 +17,7 @@ import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text';
@@ -162,6 +163,9 @@ const TASK_TOOL_POLL_HIDDEN_MS = 6000;
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT = 30;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
@@ -222,13 +226,19 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
?? getPatchText(metadata?.diff);
if (!diffText) return null;
const lines = diffText.split('\n');
let added = 0;
let removed = 0;
let lineStart = 0;
for (const line of lines) {
for (let index = 0; index <= diffText.length; index += 1) {
if (index < diffText.length && diffText.charCodeAt(index) !== 10) {
continue;
}
const line = diffText.slice(lineStart, index);
if (line.startsWith('+') && !line.startsWith('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++;
lineStart = index + 1;
}
if (added === 0 && removed === 0) return null;
@@ -237,8 +247,13 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
const parseWriteLineCount = (input?: Record<string, unknown>): number | null => {
if (!input?.content || typeof input.content !== 'string') return null;
const lines = input.content.split('\n');
return lines.length;
let lines = 1;
for (let index = 0; index < input.content.length; index += 1) {
if (input.content.charCodeAt(index) === 10) {
lines += 1;
}
}
return lines;
};
const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
@@ -291,15 +306,13 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined =
const getPatchText = (value: unknown): string | undefined => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
return /\S/.test(value) ? value : undefined;
}
if (value && typeof value === 'object') {
const patch = (value as { patch?: unknown }).patch;
if (typeof patch === 'string') {
const trimmed = patch.trim();
return trimmed.length > 0 ? trimmed : undefined;
return /\S/.test(patch) ? patch : undefined;
}
}
@@ -2073,6 +2086,24 @@ const ToolPart: React.FC<ToolPartProps> = ({
// When true, resolveFallbackTaskSessionId widens its time window (3s → 8s).
const [taskFallbackRetried, setTaskFallbackRetried] = React.useState(false);
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
return [];
}
const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
const normalized = normalizeTaskSummaryEntries(
candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
);
if (normalized.length > 0) {
return normalized;
}
return parsedTaskMetadata.summaryEntries;
}, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
const hasFinalMetadataTaskSummary = isFinalized && metadataTaskSummaryEntries.length > 0;
const explicitTaskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) {
return undefined;
@@ -2114,25 +2145,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
);
const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId;
const childSessionLookupId = hasFinalMetadataTaskSummary ? '' : (taskSessionId ?? '');
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '', currentDirectory);
useEnsureSessionMessages(taskSessionId ?? '', currentDirectory);
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
return [];
}
const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
const normalized = normalizeTaskSummaryEntries(
candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
);
if (normalized.length > 0) {
return normalized;
}
return parsedTaskMetadata.summaryEntries;
}, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
const childSessionMessages = useSessionMessageRecords(childSessionLookupId, currentDirectory);
useEnsureSessionMessages(childSessionLookupId, currentDirectory);
const childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool || !taskSessionId) {
@@ -2224,7 +2240,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
return;
}
@@ -2293,6 +2309,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
activeLatched,
isFinalized,
isTaskTool,
@@ -2303,7 +2320,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return;
}
@@ -2315,7 +2332,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: capturedSessionId,
limit: TASK_TOOL_INITIAL_FETCH_LIMIT,
limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
});
if (cancelled) {
@@ -2357,6 +2374,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
};
}, [
currentDirectory,
hasFinalMetadataTaskSummary,
isTaskTool,
taskChildPollingStopped,
taskPendingFinalFetch,
@@ -2402,6 +2420,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
}
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
if (hasFinalMetadataTaskSummary) {
return;
}
const shouldPoll =
!taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
@@ -2421,6 +2443,16 @@ const ToolPart: React.FC<ToolPartProps> = ({
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isVSCodeRuntime()) {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT;
}
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
@@ -2503,6 +2535,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
isActive,
isTaskTool,
taskPendingFinalFetch,
@@ -2522,8 +2555,14 @@ const ToolPart: React.FC<ToolPartProps> = ({
onContentChange?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
const diffStats = (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') ? parseDiffStats(metadata) : null;
const writeLineCount = normalizedPartTool === 'write' ? parseWriteLineCount(input) : null;
const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
? parseDiffStats(metadata)
: null;
}, [metadata, normalizedPartTool]);
const writeLineCount = React.useMemo(() => {
return normalizedPartTool === 'write' ? parseWriteLineCount(input) : null;
}, [input, normalizedPartTool]);
const isMultiFileApplyPatch = normalizedPartTool === 'apply_patch' && Array.isArray(metadata?.files) && (metadata?.files as []).length > 1;
const normalizedPart = normalizedPartTool !== part.tool ? ({ ...part, tool: normalizedPartTool } as ToolPartType) : part;
const descriptionPath = getToolDescriptionPath(normalizedPart, state, currentDirectory);
@@ -5,7 +5,7 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
@@ -136,15 +136,30 @@ export const VSCodeLayout: React.FC = () => {
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.sessionFallback');
}, [currentSessionId, sessions, t]);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const activeSessionTitleValue = useDirectorySync(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.session.find((session) => session.id === currentSessionId)?.title || null;
}, [currentSessionId]),
);
const initialSessionExists = useDirectorySync(
React.useCallback((state) => {
if (!initialSessionId) {
return false;
}
return state.session.some((session) => session.id === initialSessionId);
}, [initialSessionId]),
);
const activeSessionTitle = currentSessionId
? activeSessionTitleValue || t('vscodeLayout.title.sessionFallback')
: null;
const chatTitle = newSessionDraftOpen && !currentSessionId
? t('vscodeLayout.title.newSession')
: activeSessionTitle || t('vscodeLayout.title.chat');
const isSyncingMessages = useViewportStore((state) => state.isSyncing);
const hasActiveSessionWork = useDirectorySync((state) => {
const statuses = state.session_status;
@@ -351,13 +366,13 @@ export const VSCodeLayout: React.FC = () => {
return;
}
if (!sessions.some((session) => session.id === initialSessionId)) {
if (!initialSessionExists) {
return;
}
hasAppliedInitialSession.current = true;
void useSessionUIStore.getState().setCurrentSession(initialSessionId);
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]);
}, [connectionStatus, hasInitializedOnce, initialSessionExists, initialSessionId, openNewSessionDraft, viewMode]);
// Track container width for responsive settings layout
React.useEffect(() => {
@@ -433,7 +448,7 @@ export const VSCodeLayout: React.FC = () => {
// Editor mode: just chat, no sidebar
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
title={activeSessionTitle || t('vscodeLayout.title.chat')}
showMcp
showContextUsage
showRateLimits
@@ -484,9 +499,7 @@ export const VSCodeLayout: React.FC = () => {
{/* Chat content */}
<div className="flex-1 flex flex-col min-w-0">
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
title={chatTitle}
showMcp
showContextUsage
showRateLimits
@@ -503,26 +516,26 @@ export const VSCodeLayout: React.FC = () => {
// Compact layout: drill-down between sessions list and chat
<>
{/* Sessions list view */}
<div className={cn('flex flex-col h-full', currentView !== 'sessions' && 'hidden')}>
<VSCodeHeader
title={t('vscodeLayout.title.sessions')}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
{currentView === 'sessions' ? (
<div className="flex flex-col h-full">
<VSCodeHeader
title={t('vscodeLayout.title.sessions')}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
/>
</div>
</div>
</div>
) : null}
{/* Chat view */}
<div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}>
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
title={chatTitle}
showBack
onBack={handleBackToSessions}
showMcp
@@ -580,17 +593,39 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}, [loadQuotaSettings]);
const currentModel = getCurrentModel();
const latestAssistantModel = React.useMemo(() => {
const headerMessageSummary = React.useMemo(() => {
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
let latestAssistantModel: ReturnType<typeof getCurrentModel> | undefined;
let lastTokens: AssistantTokens | undefined;
let lastMessageId: string | undefined;
for (let i = currentSessionMessages.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessages[i] as { role?: unknown; providerID?: unknown; modelID?: unknown };
if (message.role !== 'assistant') continue;
if (typeof message.providerID !== 'string' || typeof message.modelID !== 'string') continue;
const provider = providers.find((entry) => entry.id === message.providerID);
const model = provider?.models.find((entry) => entry.id === message.modelID);
if (model) return model;
const message = currentSessionMessages[i] as { role?: unknown; providerID?: unknown; modelID?: unknown; tokens?: AssistantTokens };
if (message.role !== 'assistant') {
continue;
}
if (!latestAssistantModel && typeof message.providerID === 'string' && typeof message.modelID === 'string') {
const provider = providers.find((entry) => entry.id === message.providerID);
latestAssistantModel = provider?.models.find((entry) => entry.id === message.modelID);
}
if (!lastTokens && message.tokens) {
const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0);
if (total > 0) {
lastTokens = message.tokens;
lastMessageId = (currentSessionMessages[i] as { id?: string }).id;
}
}
if (latestAssistantModel && lastTokens) {
break;
}
}
return undefined;
return { latestAssistantModel, lastTokens, lastMessageId };
}, [currentSessionMessages, providers]);
const latestAssistantModel = headerMessageSummary.latestAssistantModel;
const modelForLimits = currentModel?.limit ? currentModel : latestAssistantModel;
const limit = modelForLimits && typeof modelForLimits.limit === 'object' && modelForLimits.limit !== null
? (modelForLimits.limit as Record<string, unknown>)
@@ -599,31 +634,11 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const outputLimit = limit && typeof limit.output === 'number' ? limit.output : 0;
const contextUsage = React.useMemo<SessionContextUsage | null>(() => {
if (!currentSessionId || currentSessionMessages.length === 0) {
return null;
}
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
let lastTokens: AssistantTokens | undefined;
let lastMessageId: string | undefined;
for (let i = currentSessionMessages.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessages[i];
if (message.role !== 'assistant') continue;
const tokens = (message as { tokens?: AssistantTokens }).tokens;
if (!tokens) continue;
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0);
if (total > 0) {
lastTokens = tokens;
lastMessageId = message.id;
break;
}
}
if (!lastTokens) {
if (!currentSessionId || !headerMessageSummary.lastTokens) {
return null;
}
const lastTokens = headerMessageSummary.lastTokens;
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
@@ -636,9 +651,9 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
outputLimit: outputLimit || undefined,
normalizedOutput,
thresholdLimit,
lastMessageId,
lastMessageId: headerMessageSummary.lastMessageId,
};
}, [contextLimit, currentSessionId, currentSessionMessages, outputLimit]);
}, [contextLimit, currentSessionId, headerMessageSummary.lastMessageId, headerMessageSummary.lastTokens, outputLimit]);
const [stableContextUsage, setStableContextUsage] = React.useState<SessionContextUsage | null>(null);
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved;
@@ -11,12 +11,10 @@ import { Icon } from '@/components/icon/Icon';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSync } from '@/sync/use-sync';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { formatSessionCompactDateLabel, normalizePath, resolveSessionDiffStats } from './sidebar/utils';
import { formatSessionCompactDateLabel, resolveSessionDiffStats } from './sidebar/utils';
import type { SessionNode, SessionSummaryMeta } from './sidebar/types';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -72,9 +70,6 @@ type SwitcherContentProps = {
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId });
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const ensureSessionRenderable = useSync().ensureSessionRenderable;
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const { t } = useI18n();
@@ -85,48 +80,6 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
openNewSessionDraft();
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
const prefetchedRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
// Prefetch only sessions that live in the currently mounted sync directory.
// ensureSessionRenderable closes over SyncProvider's directory, so a cross-directory
// call would hit the wrong backend context. Switching to such a session re-mounts
// SyncProvider anyway, so the prefetch wouldn't have survived either way.
const normalizedCurrent = normalizePath(currentDirectory);
const queue: string[] = [];
for (const item of items) {
const id = item.node.session.id;
if (id === currentSessionId) continue;
if (prefetchedRef.current.has(id)) continue;
const sessionDir = normalizePath(resolveGlobalSessionDirectory(item.node.session));
if (!sessionDir || sessionDir !== normalizedCurrent) continue;
prefetchedRef.current.add(id);
queue.push(id);
}
if (queue.length === 0) return;
let cancelled = false;
const CONCURRENCY = 2;
const runNext = async (): Promise<void> => {
while (!cancelled) {
const id = queue.shift();
if (!id) return;
try {
await ensureSessionRenderable(id);
} catch {
// best-effort prefetch; ignore errors
}
}
};
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, () => runNext());
void Promise.all(workers);
return () => {
cancelled = true;
};
}, [currentDirectory, currentSessionId, ensureSessionRenderable, items]);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => {
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { isVSCodeRuntime } from '@/lib/desktop';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600;
@@ -19,9 +20,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
const pumpSessionPrefetchQueue = React.useCallback(() => {
if (typeof window === 'undefined') {
if (prefetchDisabled || typeof window === 'undefined') {
return;
}
@@ -49,10 +51,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
pumpSessionPrefetchQueue();
});
}
}, [ensureSessionRenderable]);
}, [ensureSessionRenderable, prefetchDisabled]);
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
return;
}
@@ -84,12 +86,12 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
pumpSessionPrefetchQueue();
}, SESSION_PREFETCH_HOVER_DELAY_MS);
sessionPrefetchTimersRef.current.set(sessionId, timer);
}, [currentSessionId, pumpSessionPrefetchQueue]);
}, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]);
// Wait for the active session to finish loading before prefetching neighbors.
// On rapid session switches the timer resets, so only the final session triggers prefetch.
React.useEffect(() => {
if (!currentSessionId || sortedSessions.length === 0) {
if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
return;
}
const timer = window.setTimeout(() => {
@@ -99,10 +101,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
}, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
React.useEffect(() => {
if (!currentSessionId || recentSessionIds.length === 0) {
if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) {
return;
}
const timer = window.setTimeout(() => {
@@ -112,7 +114,7 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
}, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]);
React.useEffect(() => {
const prefetchTimers = sessionPrefetchTimersRef.current;
@@ -4,8 +4,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches, useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitAllBranches } from '@/stores/useGitStore';
import type { SessionNode } from '../types';
import { compareSessionsByPinnedAndTime, isPathWithinProject } from '../utils';
@@ -46,8 +45,6 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const branchesByDirectory = useGitAllBranches();
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const { git: gitApi } = useRuntimeAPIs();
const normalizedProjects = React.useMemo(
() => projects
@@ -123,16 +120,5 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId]);
React.useEffect(() => {
if (!enabled || !gitApi) return;
const seen = new Set<string>();
for (const item of items) {
const dir = item.groupDirectory;
if (!dir || seen.has(dir)) continue;
seen.add(dir);
void ensureGitStatus(dir, gitApi).catch(() => {});
}
}, [enabled, ensureGitStatus, gitApi, items]);
return items;
};