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, useSessionMessageRecords,
useSessions, useSessions,
useDirectorySync, useDirectorySync,
useSyncDirectory,
useSessionStatus, useSessionStatus,
} from '@/sync/sync-context'; } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization'; import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection'; import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs'; import { getAllSyncSessions } from '@/sync/sync-refs';
@@ -342,6 +344,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// Sync actions // Sync actions
const sync = useSync(); const sync = useSync();
const syncDirectory = useSyncDirectory();
const ensureSessionRenderable = React.useCallback( const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId), (sessionId: string) => sync.ensureSessionRenderable(sessionId),
[sync], [sync],
@@ -384,12 +387,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// Messages from sync system // Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; 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 // Sessions from sync system
const sessions = useSessions(); const sessions = useSessions();
// Plan detection - watches messages for plan creation and signals store // Plan detection - watches messages for plan creation and signals store
usePlanDetection(currentSessionId ?? ''); usePlanDetection(currentSessionId ?? '', sessionMessages);
// Session status from sync system // Session status from sync system
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; 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 // History metadata — use sync's hasMore/isLoading
const historyMeta = React.useMemo(() => { const historyMeta = React.useMemo(() => {
if (!currentSessionId) return null; if (!currentSessionId) return null;
const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true;
return { return {
limit: sessionMessages.length, limit: sessionMessages.length,
complete: !sync.hasMore(currentSessionId), complete: !(sync.hasMore(currentSessionId) || prefetchHasMore),
loading: sync.isLoading(currentSessionId), loading: sync.isLoading(currentSessionId),
}; };
}, [currentSessionId, sessionMessages.length, sync]); }, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open); 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"> <div className="relative flex flex-col h-full bg-background">
{returnToParentButton} {returnToParentButton}
<ChatViewport <ChatViewport
key={currentSessionId}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput} isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile} isMobile={isMobile}
@@ -27,6 +27,7 @@ import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types'; import type { EditorAPI } from '@/lib/api/types';
import { isVSCodeRuntime } from '@/lib/desktop';
const useCurrentMermaidTheme = () => { const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem(); const themeSystem = useOptionalThemeSystem();
@@ -714,6 +715,8 @@ const normalizeCodeBlockText = (code: string, language: string): string => {
}; };
const CODE_HIGHLIGHT_SETTLE_MS = 300; 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 = { const CODE_SHARED_STYLE: React.CSSProperties = {
margin: 0, margin: 0,
background: 'transparent', background: 'transparent',
@@ -722,6 +725,23 @@ const CODE_SHARED_STYLE: React.CSSProperties = {
lineHeight: 'var(--markdown-code-block-line-height)', 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) => { const downloadTextFile = (content: string, filename: string, mimeType: string) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -753,6 +773,7 @@ const MarkdownCodeBlock: React.FC<{
const prevCodeRef = React.useRef<string>(code); const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo(); const { isMobile, isTablet } = useDeviceInfo();
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
const canPreview = language === 'html' || language === 'htm'; const canPreview = language === 'html' || language === 'htm';
@@ -852,7 +873,7 @@ const MarkdownCodeBlock: React.FC<{
</div> </div>
) : ( ) : (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
{highlight ? ( {highlight && !skipHighlight ? (
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={syntaxTheme} style={syntaxTheme}
@@ -1023,10 +1044,22 @@ interface MarkdownRendererProps {
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
const FILE_REFERENCE_STAT_CONCURRENCY = 4; 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>>(); const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0; let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = []; 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 = { type ParsedFileReference = {
path: string; path: string;
line?: number; line?: number;
@@ -1300,6 +1333,8 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath); const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) { if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached; return cached;
} }
@@ -1326,6 +1361,14 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
pendingFileReferenceStats.push(run); 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); FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request; return request;
}; };
@@ -1362,6 +1405,7 @@ const useFileReferenceInteractions = ({
return; return;
} }
let cancelled = false; let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
const clearFileLinkAttributes = (candidate: HTMLElement) => { const clearFileLinkAttributes = (candidate: HTMLElement) => {
candidate.removeAttribute('data-openchamber-file-link'); 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 annotateFileLinks = () => {
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a'); const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
let linkedCount = 0;
for (const candidate of Array.from(candidates)) { for (const candidate of Array.from(candidates)) {
const rawCandidate = extractPathCandidateFromElement(candidate); const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory); const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate); clearFileLinkAttributes(candidate);
if (!enabled || !resolved) { if (!resolved) {
continue; continue;
} }
if (linkedCount >= fileReferenceLinkLimit) {
continue;
}
linkedCount += 1;
void fileReferenceExists(resolved.resolvedPath).then((exists) => { void fileReferenceExists(resolved.resolvedPath).then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) { if (cancelled || !exists || !container.contains(candidate)) {
return; return;
@@ -984,7 +984,7 @@ const StaticHistoryList: React.FC<{
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0)) ? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
: 0; : 0;
if (!shouldVirtualize || (virtualRows.length === 0 && entries.length > 0)) { if (!shouldVirtualize) {
return ( return (
<div ref={contentRef} className="relative w-full"> <div ref={contentRef} className="relative w-full">
{entries.map((entry) => ( {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 ( return (
<div ref={contentRef} className="relative w-full"> <div ref={contentRef} className="relative w-full">
{paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null} {paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null}
@@ -13,6 +13,7 @@ import {
} from '../lib/turns/windowTurns'; } from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals'; import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes'; import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
import { isVSCodeRuntime } from '@/lib/desktop';
type ViewportAnchor = { messageId: string; offsetTop: number }; type ViewportAnchor = { messageId: string; offsetTop: number };
@@ -60,7 +61,29 @@ export interface UseChatTimelineControllerResult {
} }
const TURN_MODEL_CACHE_MAX = 30 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 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 = ({ export const useChatTimelineController = ({
sessionId, sessionId,
@@ -80,6 +103,7 @@ export const useChatTimelineController = ({
const key = sessionId ?? "" const key = sessionId ?? ""
const cached = key ? turnModelCache.get(key) : undefined const cached = key ? turnModelCache.get(key) : undefined
if (cached && cached.messages === messages) { if (cached && cached.messages === messages) {
rememberTurnModel(key, cached)
previousTurnWindowModelRef.current = cached.model previousTurnWindowModelRef.current = cached.model
previousMessagesRef.current = messages previousMessagesRef.current = messages
return cached.model return cached.model
@@ -95,12 +119,7 @@ export const useChatTimelineController = ({
previousMessagesRef.current = messages; previousMessagesRef.current = messages;
if (key && messages.length > 0) { if (key && messages.length > 0) {
// LRU-like eviction: delete oldest when at capacity rememberTurnModel(key, { messages, model: nextModel })
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 })
} }
return nextModel; return nextModel;
@@ -42,7 +42,6 @@ import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig'; import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { extractLoopbackUrls } from '@/lib/url'; import { extractLoopbackUrls } from '@/lib/url';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
@@ -986,8 +985,16 @@ const AssistantMessageBody = React.memo(({
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
const openContextPreview = useUIStore((state) => state.openContextPreview); 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(() => { const messagePreviewUrl = React.useMemo(() => {
if (isVSCode || isMobile || isMiniChatSurface) {
return null;
}
for (const part of assistantTextParts) { for (const part of assistantTextParts) {
const text = (part as { text?: unknown }).text; const text = (part as { text?: unknown }).text;
if (typeof text !== 'string' || text.length === 0) { 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 url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
} }
return null; return null;
}, [assistantTextParts, toolParts]); }, [assistantTextParts, isMobile, isMiniChatSurface, isVSCode, toolParts]);
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage); const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getDirectoryForSession = useSessionUIStore((state) => state.getDirectoryForSession);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const effectiveDirectory = useEffectiveDirectory(); const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -1028,25 +1035,22 @@ const AssistantMessageBody = React.memo(({
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks); const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
const isSortedRenderMode = chatRenderMode === 'sorted'; const isSortedRenderMode = chatRenderMode === 'sorted';
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const collapsedPreviewCount = 7; const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop'; 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 availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentProjectRef = React.useMemo(() => { const currentProjectRef = React.useMemo(() => {
if (!canUseProjectPlanActions) {
return null;
}
const directory = effectiveDirectory const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : ''); ?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null)
?? '';
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null; 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; const hasTools = toolParts.length > 0;
@@ -1743,7 +1747,6 @@ const AssistantMessageBody = React.memo(({
}, [messageCompletedAt, messageCreatedAt]); }, [messageCompletedAt, messageCreatedAt]);
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1'; const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const isVSCode = isVSCodeRuntime();
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode; const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
const finalTurnActionButtons = ( const finalTurnActionButtons = (
@@ -1760,7 +1763,7 @@ const AssistantMessageBody = React.memo(({
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onClick={() => { onClick={() => {
const directory = effectiveDirectory const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : null); ?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null);
if (!directory) { if (!directory) {
return; return;
} }
@@ -1773,7 +1776,7 @@ const AssistantMessageBody = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent> <TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{!isMiniChatSurface && !isVSCode ? ( {canUseProjectPlanActions ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -1809,7 +1812,7 @@ const AssistantMessageBody = React.memo(({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent> <TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip> : null} </Tooltip> : null}
{!isMiniChatSurface && !isVSCode ? ( {canShowMultiRunAction ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -1840,14 +1843,16 @@ const AssistantMessageBody = React.memo(({
style={CONTAIN_LAYOUT_STYLE} style={CONTAIN_LAYOUT_STYLE}
> >
<TextSelectionMenu containerRef={messageContentRef} /> <TextSelectionMenu containerRef={messageContentRef} />
<SaveProjectPlanDialog {canUseProjectPlanActions ? (
open={isPlanDialogOpen} <SaveProjectPlanDialog
onOpenChange={setIsPlanDialogOpen} open={isPlanDialogOpen}
initialTitle={suggestedPlanTitle} onOpenChange={setIsPlanDialogOpen}
sourceText={assistantPlanText} initialTitle={suggestedPlanTitle}
saving={isSavingPlan} sourceText={assistantPlanText}
onSave={handleConfirmSaveAsPlan} saving={isSavingPlan}
/> onSave={handleConfirmSaveAsPlan}
/>
) : null}
<div> <div>
<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" 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 { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity'; import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text'; 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_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160; const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80; 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_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500; const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const TASK_TOOL_FALLBACK_RETRY_MS = 3000; const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
@@ -222,13 +226,19 @@ const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; re
?? getPatchText(metadata?.diff); ?? getPatchText(metadata?.diff);
if (!diffText) return null; if (!diffText) return null;
const lines = diffText.split('\n');
let added = 0; let added = 0;
let removed = 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('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++; if (line.startsWith('-') && !line.startsWith('---')) removed++;
lineStart = index + 1;
} }
if (added === 0 && removed === 0) return null; 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 => { const parseWriteLineCount = (input?: Record<string, unknown>): number | null => {
if (!input?.content || typeof input.content !== 'string') return null; if (!input?.content || typeof input.content !== 'string') return null;
const lines = input.content.split('\n'); let lines = 1;
return lines.length; 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 => { const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
@@ -291,15 +306,13 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined =
const getPatchText = (value: unknown): string | undefined => { const getPatchText = (value: unknown): string | undefined => {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); return /\S/.test(value) ? value : undefined;
return trimmed.length > 0 ? trimmed : undefined;
} }
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
const patch = (value as { patch?: unknown }).patch; const patch = (value as { patch?: unknown }).patch;
if (typeof patch === 'string') { if (typeof patch === 'string') {
const trimmed = patch.trim(); return /\S/.test(patch) ? patch : undefined;
return trimmed.length > 0 ? trimmed : undefined;
} }
} }
@@ -2073,6 +2086,24 @@ const ToolPart: React.FC<ToolPartProps> = ({
// When true, resolveFallbackTaskSessionId widens its time window (3s → 8s). // When true, resolveFallbackTaskSessionId widens its time window (3s → 8s).
const [taskFallbackRetried, setTaskFallbackRetried] = React.useState(false); 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>(() => { const explicitTaskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) { if (!isTaskTool) {
return undefined; return undefined;
@@ -2114,25 +2145,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
); );
const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId; const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId;
const childSessionLookupId = hasFinalMetadataTaskSummary ? '' : (taskSessionId ?? '');
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '', currentDirectory); const childSessionMessages = useSessionMessageRecords(childSessionLookupId, currentDirectory);
useEnsureSessionMessages(taskSessionId ?? '', currentDirectory); useEnsureSessionMessages(childSessionLookupId, 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 childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => { const childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool || !taskSessionId) { if (!isTaskTool || !taskSessionId) {
@@ -2224,7 +2240,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
]); ]);
React.useEffect(() => { React.useEffect(() => {
if (!isTaskTool || !taskSessionId) { if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
return; return;
} }
@@ -2293,6 +2309,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
childSessionHasInFlightTools, childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length, childSessionTaskSummaryEntries.length,
currentDirectory, currentDirectory,
hasFinalMetadataTaskSummary,
activeLatched, activeLatched,
isFinalized, isFinalized,
isTaskTool, isTaskTool,
@@ -2303,7 +2320,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
]); ]);
React.useEffect(() => { React.useEffect(() => {
if (!isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) { if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return; return;
} }
@@ -2315,7 +2332,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory); const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({ const response = await scopedClient.session.messages({
sessionID: capturedSessionId, sessionID: capturedSessionId,
limit: TASK_TOOL_INITIAL_FETCH_LIMIT, limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
}); });
if (cancelled) { if (cancelled) {
@@ -2357,6 +2374,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
}; };
}, [ }, [
currentDirectory, currentDirectory,
hasFinalMetadataTaskSummary,
isTaskTool, isTaskTool,
taskChildPollingStopped, taskChildPollingStopped,
taskPendingFinalFetch, taskPendingFinalFetch,
@@ -2402,6 +2420,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
} }
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry'; const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
if (hasFinalMetadataTaskSummary) {
return;
}
const shouldPoll = const shouldPoll =
!taskChildPollingStopped !taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0); && (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
@@ -2421,6 +2443,16 @@ const ToolPart: React.FC<ToolPartProps> = ({
}; };
const resolveFetchLimit = (isInitialFetch: boolean) => { 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) { if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT; return TASK_TOOL_INITIAL_FETCH_LIMIT;
} }
@@ -2503,6 +2535,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
childSessionHasInFlightTools, childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length, childSessionTaskSummaryEntries.length,
currentDirectory, currentDirectory,
hasFinalMetadataTaskSummary,
isActive, isActive,
isTaskTool, isTaskTool,
taskPendingFinalFetch, taskPendingFinalFetch,
@@ -2522,8 +2555,14 @@ const ToolPart: React.FC<ToolPartProps> = ({
onContentChange?.('structural'); onContentChange?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]); }, [isTaskTool, onContentChange, taskSummaryEntries.length]);
const diffStats = (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') ? parseDiffStats(metadata) : null; const diffStats = React.useMemo(() => {
const writeLineCount = normalizedPartTool === 'write' ? parseWriteLineCount(input) : null; 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 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 normalizedPart = normalizedPartTool !== part.tool ? ({ ...part, tool: normalizedPartTool } as ToolPartType) : part;
const descriptionPath = getToolDescriptionPath(normalizedPart, state, currentDirectory); const descriptionPath = getToolDescriptionPath(normalizedPart, state, currentDirectory);
@@ -5,7 +5,7 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ChatView } from '@/components/views/ChatView'; import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-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 { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown'; import { McpDropdown } from '@/components/mcp/McpDropdown';
@@ -136,15 +136,30 @@ export const VSCodeLayout: React.FC = () => {
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH); const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null); const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); 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 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 isSyncingMessages = useViewportStore((state) => state.isSyncing);
const hasActiveSessionWork = useDirectorySync((state) => { const hasActiveSessionWork = useDirectorySync((state) => {
const statuses = state.session_status; const statuses = state.session_status;
@@ -351,13 +366,13 @@ export const VSCodeLayout: React.FC = () => {
return; return;
} }
if (!sessions.some((session) => session.id === initialSessionId)) { if (!initialSessionExists) {
return; return;
} }
hasAppliedInitialSession.current = true; hasAppliedInitialSession.current = true;
void useSessionUIStore.getState().setCurrentSession(initialSessionId); void useSessionUIStore.getState().setCurrentSession(initialSessionId);
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]); }, [connectionStatus, hasInitializedOnce, initialSessionExists, initialSessionId, openNewSessionDraft, viewMode]);
// Track container width for responsive settings layout // Track container width for responsive settings layout
React.useEffect(() => { React.useEffect(() => {
@@ -433,7 +448,7 @@ export const VSCodeLayout: React.FC = () => {
// Editor mode: just chat, no sidebar // Editor mode: just chat, no sidebar
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<VSCodeHeader <VSCodeHeader
title={sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')} title={activeSessionTitle || t('vscodeLayout.title.chat')}
showMcp showMcp
showContextUsage showContextUsage
showRateLimits showRateLimits
@@ -484,9 +499,7 @@ export const VSCodeLayout: React.FC = () => {
{/* Chat content */} {/* Chat content */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<VSCodeHeader <VSCodeHeader
title={newSessionDraftOpen && !currentSessionId title={chatTitle}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showMcp showMcp
showContextUsage showContextUsage
showRateLimits showRateLimits
@@ -503,26 +516,26 @@ export const VSCodeLayout: React.FC = () => {
// Compact layout: drill-down between sessions list and chat // Compact layout: drill-down between sessions list and chat
<> <>
{/* Sessions list view */} {/* Sessions list view */}
<div className={cn('flex flex-col h-full', currentView !== 'sessions' && 'hidden')}> {currentView === 'sessions' ? (
<VSCodeHeader <div className="flex flex-col h-full">
title={t('vscodeLayout.title.sessions')} <VSCodeHeader
/> title={t('vscodeLayout.title.sessions')}
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
/> />
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
/>
</div>
</div> </div>
</div> ) : null}
{/* Chat view */} {/* Chat view */}
<div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}> <div className={cn('flex flex-col h-full', currentView !== 'chat' && 'hidden')}>
<VSCodeHeader <VSCodeHeader
title={newSessionDraftOpen && !currentSessionId title={chatTitle}
? t('vscodeLayout.title.newSession')
: sessions.find((session) => session.id === currentSessionId)?.title || t('vscodeLayout.title.chat')}
showBack showBack
onBack={handleBackToSessions} onBack={handleBackToSessions}
showMcp showMcp
@@ -580,17 +593,39 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}, [loadQuotaSettings]); }, [loadQuotaSettings]);
const currentModel = getCurrentModel(); 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) { for (let i = currentSessionMessages.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessages[i] as { role?: unknown; providerID?: unknown; modelID?: unknown }; const message = currentSessionMessages[i] as { role?: unknown; providerID?: unknown; modelID?: unknown; tokens?: AssistantTokens };
if (message.role !== 'assistant') continue; if (message.role !== 'assistant') {
if (typeof message.providerID !== 'string' || typeof message.modelID !== 'string') continue; continue;
const provider = providers.find((entry) => entry.id === message.providerID); }
const model = provider?.models.find((entry) => entry.id === message.modelID);
if (model) return model; 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]); }, [currentSessionMessages, providers]);
const latestAssistantModel = headerMessageSummary.latestAssistantModel;
const modelForLimits = currentModel?.limit ? currentModel : latestAssistantModel; const modelForLimits = currentModel?.limit ? currentModel : latestAssistantModel;
const limit = modelForLimits && typeof modelForLimits.limit === 'object' && modelForLimits.limit !== null const limit = modelForLimits && typeof modelForLimits.limit === 'object' && modelForLimits.limit !== null
? (modelForLimits.limit as Record<string, unknown>) ? (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 outputLimit = limit && typeof limit.output === 'number' ? limit.output : 0;
const contextUsage = React.useMemo<SessionContextUsage | null>(() => { const contextUsage = React.useMemo<SessionContextUsage | null>(() => {
if (!currentSessionId || currentSessionMessages.length === 0) { if (!currentSessionId || !headerMessageSummary.lastTokens) {
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) {
return null; return null;
} }
const lastTokens = headerMessageSummary.lastTokens;
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; 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, outputLimit: outputLimit || undefined,
normalizedOutput, normalizedOutput,
thresholdLimit, 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 [stableContextUsage, setStableContextUsage] = React.useState<SessionContextUsage | null>(null);
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved; const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved;
@@ -11,12 +11,10 @@ import { Icon } from '@/components/icon/Icon';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store'; 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 { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; 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 type { SessionNode, SessionSummaryMeta } from './sidebar/types';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -72,9 +70,6 @@ type SwitcherContentProps = {
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement { function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId }); 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 openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const { t } = useI18n(); const { t } = useI18n();
@@ -85,48 +80,6 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
openNewSessionDraft(); openNewSessionDraft();
}, [onSelect, openNewSessionDraft, setActiveMainTab]); }, [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 [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const toggleParent = React.useCallback((sessionId: string) => { const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => { setExpandedParents((prev) => {
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs'; import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { isVSCodeRuntime } from '@/lib/desktop';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180; const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600; const SESSION_PREFETCH_SETTLE_MS = 600;
@@ -19,9 +20,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map()); const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<string[]>([]); const sessionPrefetchQueueRef = React.useRef<string[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set()); const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
const pumpSessionPrefetchQueue = React.useCallback(() => { const pumpSessionPrefetchQueue = React.useCallback(() => {
if (typeof window === 'undefined') { if (prefetchDisabled || typeof window === 'undefined') {
return; return;
} }
@@ -49,10 +51,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
pumpSessionPrefetchQueue(); pumpSessionPrefetchQueue();
}); });
} }
}, [ensureSessionRenderable]); }, [ensureSessionRenderable, prefetchDisabled]);
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => { const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') { if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
return; return;
} }
@@ -84,12 +86,12 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
pumpSessionPrefetchQueue(); pumpSessionPrefetchQueue();
}, SESSION_PREFETCH_HOVER_DELAY_MS); }, SESSION_PREFETCH_HOVER_DELAY_MS);
sessionPrefetchTimersRef.current.set(sessionId, timer); sessionPrefetchTimersRef.current.set(sessionId, timer);
}, [currentSessionId, pumpSessionPrefetchQueue]); }, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]);
// Wait for the active session to finish loading before prefetching neighbors. // 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. // On rapid session switches the timer resets, so only the final session triggers prefetch.
React.useEffect(() => { React.useEffect(() => {
if (!currentSessionId || sortedSessions.length === 0) { if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -99,10 +101,10 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]); }, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
React.useEffect(() => { React.useEffect(() => {
if (!currentSessionId || recentSessionIds.length === 0) { if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -112,7 +114,7 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS); }, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]); }, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]);
React.useEffect(() => { React.useEffect(() => {
const prefetchTimers = sessionPrefetchTimersRef.current; const prefetchTimers = sessionPrefetchTimersRef.current;
@@ -4,8 +4,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches, useGitStore } from '@/stores/useGitStore'; import { useGitAllBranches } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { SessionNode } from '../types'; import type { SessionNode } from '../types';
import { compareSessionsByPinnedAndTime, isPathWithinProject } from '../utils'; import { compareSessionsByPinnedAndTime, isPathWithinProject } from '../utils';
@@ -46,8 +45,6 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const branchesByDirectory = useGitAllBranches(); const branchesByDirectory = useGitAllBranches();
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const { git: gitApi } = useRuntimeAPIs();
const normalizedProjects = React.useMemo( const normalizedProjects = React.useMemo(
() => projects () => projects
@@ -123,16 +120,5 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
}); });
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId]); }, [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; return items;
}; };
+13 -12
View File
@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { useSessionMessages } from '@/sync/sync-context'; import type { Message, Part } from '@opencode-ai/sdk/v2';
import { getSyncParts } from '@/sync/sync-refs';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
type SessionMessageRecord = { info: Message; parts: Part[] };
/** /**
* Watches session messages for plan creation and marks sessions as plan-available. * Watches session messages for plan creation and marks sessions as plan-available.
* *
@@ -13,11 +14,10 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
* *
* The Header component subscribes to sessionPlanAvailable map to show/hide the Plan tab. * The Header component subscribes to sessionPlanAvailable map to show/hide the Plan tab.
*/ */
export const usePlanDetection = (sessionId: string, directory?: string) => { export const usePlanDetection = (sessionId: string, messageRecords: SessionMessageRecord[]) => {
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const markSessionPlanAvailable = useSessionUIStore((state) => state.markSessionPlanAvailable); const markSessionPlanAvailable = useSessionUIStore((state) => state.markSessionPlanAvailable);
const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable); const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable);
const messages = useSessionMessages(sessionId, directory);
React.useEffect(() => { React.useEffect(() => {
// Early exit if plan mode is disabled - don't parse messages // Early exit if plan mode is disabled - don't parse messages
@@ -27,15 +27,16 @@ export const usePlanDetection = (sessionId: string, directory?: string) => {
// Already marked as available - no need to check again // Already marked as available - no need to check again
if (isSessionPlanAvailable(sessionId)) return; if (isSessionPlanAvailable(sessionId)) return;
// Scan messages for plan references // Scan the already-materialized message records used by ChatContainer so
for (const message of messages) { // plan detection does not add a second active-session message subscription.
for (const message of messageRecords) {
// Only check assistant messages for plan references // Only check assistant messages for plan references
if (message.role !== 'assistant') continue; if (message.info.role !== 'assistant') continue;
const parts = getSyncParts(message.id, directory); for (const part of message.parts) {
for (const part of parts) { const record = part as { type?: string; text?: string };
if (part.type !== 'text') continue; if (record.type !== 'text') continue;
const text = (part as { text?: string }).text || ''; const text = record.text || '';
// Check for plan file reference in synthetic messages // Check for plan file reference in synthetic messages
if (text.includes('The plan at ') || text.includes('User has requested to enter plan mode')) { if (text.includes('The plan at ') || text.includes('User has requested to enter plan mode')) {
@@ -44,5 +45,5 @@ export const usePlanDetection = (sessionId: string, directory?: string) => {
} }
} }
} }
}, [planModeEnabled, sessionId, directory, messages, markSessionPlanAvailable, isSessionPlanAvailable]); }, [planModeEnabled, sessionId, messageRecords, markSessionPlanAvailable, isSessionPlanAvailable]);
}; };
+6
View File
@@ -1306,10 +1306,16 @@ export const useUIStore = create<UIStore>()(
}, },
setSessionSwitcherOpen: (open) => { setSessionSwitcherOpen: (open) => {
if (get().isSessionSwitcherOpen === open) {
return;
}
set({ isSessionSwitcherOpen: open }); set({ isSessionSwitcherOpen: open });
}, },
setSessionDropdownOpen: (open) => { setSessionDropdownOpen: (open) => {
if (get().isSessionDropdownOpen === open) {
return;
}
set({ isSessionDropdownOpen: open }); set({ isSessionDropdownOpen: open });
}, },
+27 -1
View File
@@ -21,9 +21,16 @@ const compositeKey = (directory: string, sessionID: string) =>
const cache = new Map<string, Meta>() const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>() const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>() const rev = new Map<string, number>()
const listeners = new Map<string, Set<() => void>>()
const version = (id: string) => rev.get(id) ?? 0 const version = (id: string) => rev.get(id) ?? 0
const notify = (id: string) => {
const callbacks = listeners.get(id)
if (!callbacks) return
callbacks.forEach((callback) => callback())
}
/** Check if a prefetch/sync can be skipped (recently fetched). */ /** Check if a prefetch/sync can be skipped (recently fetched). */
export function shouldSkipSessionPrefetch(input: { export function shouldSkipSessionPrefetch(input: {
hasMessages: boolean hasMessages: boolean
@@ -46,6 +53,21 @@ export function getSessionPrefetch(directory: string, sessionID: string): Meta |
return cache.get(compositeKey(directory, sessionID)) return cache.get(compositeKey(directory, sessionID))
} }
export function subscribeSessionPrefetch(directory: string, sessionID: string, callback: () => void) {
if (!sessionID) return () => undefined
const id = compositeKey(directory, sessionID)
let callbacks = listeners.get(id)
if (!callbacks) {
callbacks = new Set()
listeners.set(id, callbacks)
}
callbacks.add(callback)
return () => {
callbacks?.delete(callback)
if (callbacks?.size === 0) listeners.delete(id)
}
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) { export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(compositeKey(directory, sessionID)) return inflight.get(compositeKey(directory, sessionID))
} }
@@ -82,12 +104,14 @@ export function setSessionPrefetch(input: {
complete: boolean complete: boolean
at?: number at?: number
}) { }) {
cache.set(compositeKey(input.directory, input.sessionID), { const id = compositeKey(input.directory, input.sessionID)
cache.set(id, {
limit: input.limit, limit: input.limit,
cursor: input.cursor, cursor: input.cursor,
complete: input.complete, complete: input.complete,
at: input.at ?? Date.now(), at: input.at ?? Date.now(),
}) })
notify(id)
} }
/** Invalidate cache for specific sessions (e.g. after eviction). */ /** Invalidate cache for specific sessions (e.g. after eviction). */
@@ -98,6 +122,7 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<str
rev.set(id, version(id) + 1) rev.set(id, version(id) + 1)
cache.delete(id) cache.delete(id)
inflight.delete(id) inflight.delete(id)
notify(id)
} }
} }
@@ -110,5 +135,6 @@ export function clearSessionPrefetchDirectory(directory: string) {
rev.set(id, version(id) + 1) rev.set(id, version(id) + 1)
cache.delete(id) cache.delete(id)
inflight.delete(id) inflight.delete(id)
notify(id)
} }
} }
+3
View File
@@ -1208,6 +1208,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
markSessionPlanAvailable: (sessionId) => { markSessionPlanAvailable: (sessionId) => {
set((state) => { set((state) => {
if (state.sessionPlanAvailable.get(sessionId) === true) {
return state
}
const next = new Map(state.sessionPlanAvailable) const next = new Map(state.sessionPlanAvailable)
next.set(sessionId, true) next.set(sessionId, true)
return { sessionPlanAvailable: next } return { sessionPlanAvailable: next }
+180 -16
View File
@@ -38,6 +38,7 @@ import type { PermissionRequest } from "@/types/permission"
import type { QuestionRequest } from "@/types/question" import type { QuestionRequest } from "@/types/question"
import * as sessionActions from "./session-actions" import * as sessionActions from "./session-actions"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { setSessionPrefetch } from "./session-prefetch-cache"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Context // Context
@@ -195,6 +196,14 @@ async function materializeSessionFromServer(
) )
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id) const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return if (records.length === 0) return
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
setSessionPrefetch({
directory,
sessionID,
limit: records.length,
cursor,
complete: !cursor,
})
store.setState((state: DirectoryStore) => { store.setState((state: DirectoryStore) => {
const materialized = materializeSessionSnapshots( const materialized = materializeSessionSnapshots(
@@ -937,6 +946,14 @@ async function resyncDirectoryAfterReconnect(
const session = sessionResponse?.data const session = sessionResponse?.data
const records = messageResponse?.data const records = messageResponse?.data
if (!session || !records) return if (!session || !records) return
const cursor = messageResponse.response?.headers?.get?.("x-next-cursor") ?? undefined
setSessionPrefetch({
directory,
sessionID: sessionId,
limit: records.length,
cursor,
complete: !cursor,
})
const nextSession = stripSessionDiffSnapshots(session) const nextSession = stripSessionDiffSnapshots(session)
const nextMessages = records const nextMessages = records
@@ -1562,10 +1579,16 @@ export function useSessionRevertMessageID(sessionID: string, directory?: string)
/** Get session messages for a specific session */ /** Get session messages for a specific session */
export function useSessionMessages(sessionID: string, directory?: string) { export function useSessionMessages(sessionID: string, directory?: string) {
return useDirectorySync( const store = useDirectoryStore(directory)
useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]), const getSnapshot = useCallback(() => {
directory, if (!sessionID) return EMPTY_MESSAGES
) return store.getState().message[sessionID] ?? EMPTY_MESSAGES
}, [sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
} }
/** /**
@@ -1602,18 +1625,30 @@ export function useSessionParts(messageID: string, directory?: string) {
/** Get status for a specific session */ /** Get status for a specific session */
export function useSessionStatus(sessionID: string, directory?: string) { export function useSessionStatus(sessionID: string, directory?: string) {
return useDirectorySync( const store = useDirectoryStore(directory)
useCallback((state: State) => state.session_status?.[sessionID], [sessionID]), const getSnapshot = useCallback(() => {
directory, if (!sessionID) return undefined
) return store.getState().session_status?.[sessionID]
}, [sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
} }
/** Get permissions for a specific session */ /** Get permissions for a specific session */
export function useSessionPermissions(sessionID: string, directory?: string) { export function useSessionPermissions(sessionID: string, directory?: string) {
return useDirectorySync( const store = useDirectoryStore(directory)
useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]), const getSnapshot = useCallback(() => {
directory, if (!sessionID) return EMPTY_PERMISSION_REQUESTS
) return store.getState().permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS
}, [sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
} }
/** Get questions for a specific session */ /** Get questions for a specific session */
@@ -1812,16 +1847,121 @@ const getFirstTextFromParts = (parts: Part[]): string => {
} }
type SessionMessageRecord = { info: Message; parts: Part[] } type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
type SessionMessageRecordsSnapshot = { type SessionMessageRecordsSnapshot = {
sessionID: string sessionID: string
sourceMessages: Message[] sourceMessages: Message[]
visibleMessages: Message[] visibleMessages: Message[]
revertMessageID?: string revertMessageID?: string
suspendPartUpdates: boolean
list: SessionMessageRecord[] list: SessionMessageRecord[]
byId: Map<string, SessionMessageRecord> byId: Map<string, SessionMessageRecord>
} }
const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}`
)
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
let cache = sessionMessageRecordsCache.get(store)
if (!cache) {
cache = new Map()
sessionMessageRecordsCache.set(store, cache)
}
return cache
}
const readCachedSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>,
sessionID: string,
suspendPartUpdates: boolean,
): SessionMessageRecordsSnapshot | undefined => {
const cache = sessionMessageRecordsCache.get(store)
if (!cache) return undefined
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates)
const cached = cache.get(key)
if (!cached) return undefined
cache.delete(key)
cache.set(key, cached)
return cached
}
const rememberSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>,
snapshot: SessionMessageRecordsSnapshot,
): void => {
if (!snapshot.sessionID) return
const cache = getSessionMessageRecordsCache(store)
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
if (isVSCodeRuntime() && snapshot.list.length > VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES) {
cache.delete(key)
return
}
cache.delete(key)
cache.set(key, snapshot)
const max = isVSCodeRuntime() ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX : SESSION_MESSAGE_RECORDS_CACHE_MAX
while (cache.size > max) {
const oldest = cache.keys().next().value
if (typeof oldest !== "string") break
cache.delete(oldest)
}
}
export function dropCachedSessionMessageRecordsSnapshots(
store: StoreApi<DirectoryStore>,
sessionIDs: Iterable<string>,
): void {
const cache = sessionMessageRecordsCache.get(store)
if (!cache) return
for (const sessionID of sessionIDs) {
if (!sessionID) continue
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false))
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true))
}
}
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
if (snapshot.suspendPartUpdates) {
return true
}
for (const record of snapshot.list) {
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
return false
}
}
return true
}
const getReusableSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>,
state: State,
sessionID: string,
suspendPartUpdates: boolean,
): SessionMessageRecordsSnapshot | undefined => {
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
if (!cached) return undefined
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
const session = state.session.find((candidate) => candidate.id === sessionID)
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID
if (
cached.sourceMessages === sourceMessages
&& cached.revertMessageID === revertMessageID
&& cached.suspendPartUpdates === suspendPartUpdates
&& snapshotPartsMatchState(cached, state)
) {
return cached
}
return undefined
}
function getVisibleMessagesForSession(state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot): { function getVisibleMessagesForSession(state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot): {
sourceMessages: Message[] sourceMessages: Message[]
visibleMessages: Message[] visibleMessages: Message[]
@@ -1886,6 +2026,7 @@ export function buildSessionMessageRecordsSnapshot(
sourceMessages, sourceMessages,
visibleMessages, visibleMessages,
revertMessageID, revertMessageID,
suspendPartUpdates,
list: nextList, list: nextList,
byId: nextById, byId: nextById,
} }
@@ -1949,22 +2090,45 @@ export function useSessionMessageRecords(
sourceMessages: EMPTY_MESSAGES, sourceMessages: EMPTY_MESSAGES,
visibleMessages: EMPTY_MESSAGES, visibleMessages: EMPTY_MESSAGES,
revertMessageID: undefined, revertMessageID: undefined,
suspendPartUpdates: Boolean(options?.suspendPartUpdates),
list: [], list: [],
byId: new Map(), byId: new Map(),
}) })
const getSnapshot = useCallback(() => { const getSnapshot = useCallback(() => {
if (!sessionID) {
return EMPTY_SESSION_MESSAGE_RECORDS
}
const state = store.getState()
const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates)
if (reusableSnapshot) {
snapshotRef.current = reusableSnapshot
return reusableSnapshot.list
}
const previousSnapshot = snapshotRef.current.sessionID === sessionID
? snapshotRef.current
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
const nextSnapshot = buildSessionMessageRecordsSnapshot( const nextSnapshot = buildSessionMessageRecordsSnapshot(
store.getState(), state,
sessionID, sessionID,
snapshotRef.current.sessionID === sessionID ? snapshotRef.current : undefined, previousSnapshot,
Boolean(options?.suspendPartUpdates), suspendPartUpdates,
) )
snapshotRef.current = nextSnapshot snapshotRef.current = nextSnapshot
rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
return nextSnapshot.list return nextSnapshot.list
}, [options?.suspendPartUpdates, sessionID, store]) }, [options?.suspendPartUpdates, sessionID, store])
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot) const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
} }
/** /**
+125 -33
View File
@@ -2,15 +2,16 @@ import { useCallback, useRef, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary" import { Binary } from "./binary"
import { retry } from "./retry" import { retry } from "./retry"
import { SESSION_CACHE_LIMIT } from "./types" import { SESSION_CACHE_LIMIT, type State } from "./types"
import { pickSessionCacheEvictions } from "./session-cache" import { pickSessionCacheEvictions } from "./session-cache"
import { import {
mergeOptimisticPage, mergeOptimisticPage,
type OptimisticItem, type OptimisticItem,
} from "./optimistic" } from "./optimistic"
import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context" import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache" import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripMessageDiffSnapshots } from "./sanitize" import { stripMessageDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { import {
shouldSkipSessionPrefetch, shouldSkipSessionPrefetch,
getSessionPrefetch, getSessionPrefetch,
@@ -20,14 +21,60 @@ import {
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const MESSAGE_PAGE_SIZE = 200 const MESSAGE_PAGE_SIZE = 150
const VSCODE_MESSAGE_PAGE_SIZE = 30
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const MAX_SEEN_DIRS = 30 const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
// Shared across useSync() instances so cache eviction is based on app-level
// session recency, not whichever component happened to call sync first.
const seenByDirectory = new Map<string, Set<string>>()
type SyncMeta = {
limit: number
cursor: string | undefined
complete: boolean
loading: boolean
}
const getEffectiveSessionCacheLimit = () => isVSCodeRuntime() ? VSCODE_SESSION_CACHE_LIMIT : SESSION_CACHE_LIMIT
const getEffectiveMessagePageSize = () => isVSCodeRuntime() ? VSCODE_MESSAGE_PAGE_SIZE : MESSAGE_PAGE_SIZE
const getVSCodeInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
const getDefaultMeta = (): SyncMeta => ({ limit: getEffectiveMessagePageSize(), cursor: undefined, complete: false, loading: false })
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
const info = getSessionPrefetch(directory, sessionID)
if (!info) return undefined
return {
limit: info.limit,
cursor: info.cursor,
complete: info.complete,
loading: false,
}
}
function sortParts(parts: Part[]) { function sortParts(parts: Part[]) {
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
} }
function isHeavyVSCodeSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
const messages = state.message[sessionID]
if (!messages || messages.length === 0) return false
return messages.length > VSCODE_MESSAGE_PAGE_SIZE
}
function isUserMessage(message: Message): boolean {
const info = message as Message & { clientRole?: unknown; role?: unknown }
const role = typeof info.clientRole === "string" ? info.clientRole : info.role
return role === "user"
}
function hasUserMessage(messages: Message[] | undefined): boolean {
return Boolean(messages?.some(isUserMessage))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates // useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates // Message loading, pagination, optimistic updates
@@ -42,13 +89,7 @@ export function useSync() {
// Refs for mutable tracking (no re-renders) // Refs for mutable tracking (no re-renders)
const inflight = useRef(new Map<string, Promise<void>>()) const inflight = useRef(new Map<string, Promise<void>>())
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>()) const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
const seen = useRef(new Map<string, Set<string>>()) const meta = useRef(new Map<string, SyncMeta>())
const meta = useRef(new Map<string, {
limit: number
cursor: string | undefined
complete: boolean
loading: boolean
}>())
const keyFor = useCallback( const keyFor = useCallback(
(sessionID: string) => `${directory}\n${sessionID}`, (sessionID: string) => `${directory}\n${sessionID}`,
@@ -58,18 +99,18 @@ export function useSync() {
const getMetaFor = useCallback( const getMetaFor = useCallback(
(sessionID: string) => { (sessionID: string) => {
const key = keyFor(sessionID) const key = keyFor(sessionID)
return meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } return meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
}, },
[keyFor], [directory, keyFor],
) )
const setMetaFor = useCallback( const setMetaFor = useCallback(
(sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => { (sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => {
const key = keyFor(sessionID) const key = keyFor(sessionID)
const current = meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } const current = meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
meta.current.set(key, { ...current, ...patch }) meta.current.set(key, { ...current, ...patch })
}, },
[keyFor], [directory, keyFor],
) )
// Session cache eviction — two levels of LRU: // Session cache eviction — two levels of LRU:
@@ -93,6 +134,7 @@ export function useSync() {
question: { ...current.question }, question: { ...current.question },
} }
dropSessionCaches(draft, sessionIDs) dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
dirStore.setState(draft) dirStore.setState(draft)
// Clear meta + optimistic + prefetch cache for evicted sessions // Clear meta + optimistic + prefetch cache for evicted sessions
@@ -109,22 +151,22 @@ export function useSync() {
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches. // When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS. // LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
const seenFor = useCallback(() => { const seenFor = useCallback(() => {
const existing = seen.current.get(directory) const existing = seenByDirectory.get(directory)
if (existing) { if (existing) {
// LRU reorder: delete + re-insert moves to end (most recent) // LRU reorder: delete + re-insert moves to end (most recent)
seen.current.delete(directory) seenByDirectory.delete(directory)
seen.current.set(directory, existing) seenByDirectory.set(directory, existing)
return existing return existing
} }
const created = new Set<string>() const created = new Set<string>()
seen.current.set(directory, created) seenByDirectory.set(directory, created)
// Evict oldest directories if over limit // Evict oldest directories if over limit
while (seen.current.size > MAX_SEEN_DIRS) { while (seenByDirectory.size > MAX_SEEN_DIRS) {
const first = seen.current.keys().next().value const first = seenByDirectory.keys().next().value
if (!first) break if (!first) break
const staleSessionIds = [...(seen.current.get(first) ?? [])] const staleSessionIds = [...(seenByDirectory.get(first) ?? [])]
seen.current.delete(first) seenByDirectory.delete(first)
evict(first, staleSessionIds) evict(first, staleSessionIds)
} }
@@ -136,13 +178,34 @@ export function useSync() {
(sessionID: string) => { (sessionID: string) => {
const s = seenFor() const s = seenFor()
const protectedIds = getProtectedSessionCacheIds(store.getState()) const protectedIds = getProtectedSessionCacheIds(store.getState())
const cacheLimit = getEffectiveSessionCacheLimit()
const stale = pickSessionCacheEvictions({ const stale = pickSessionCacheEvictions({
seen: s, seen: s,
keep: sessionID, keep: sessionID,
limit: SESSION_CACHE_LIMIT, limit: cacheLimit,
preserve: protectedIds, preserve: protectedIds,
}) })
evict(directory, stale) evict(directory, stale)
if (isVSCodeRuntime()) {
const state = store.getState()
const keep = new Set([sessionID, ...s, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(directory, prefetched)
// One very large inactive session can create memory/GC pressure that
// makes later small-session switches feel slow. Keep it while active,
// but do not retain it as a warm cache in the VSCode webview.
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
if (id === sessionID || protectedIds.has(id)) return false
return isHeavyVSCodeSessionCache(afterPrefetchEviction, id)
})
if (heavyInactive.length > 0) {
for (const id of heavyInactive) s.delete(id)
evict(directory, heavyInactive)
}
}
}, },
[directory, seenFor, evict, store], [directory, seenFor, evict, store],
) )
@@ -213,8 +276,21 @@ export function useSync() {
setMetaFor(sessionID, { loading: true }) setMetaFor(sessionID, { loading: true })
try { try {
const limit = m.limit const limit = options?.before ? getEffectiveMessagePageSize() : m.limit
const page = await fetchMessages(sessionID, limit, options?.before) let page = await fetchMessages(sessionID, limit, options?.before)
// VSCode keeps the initial page small for switch performance. Some
// sessions have a very large final turn, so the latest 30 records can
// contain only assistant/tool records and no user boundary. That makes
// turn projection render an empty chat until the user manually loads
// older messages. Expand only this initial tail fetch, with a hard cap.
if (!options?.before && isVSCodeRuntime() && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break
}
}
// Merge optimistic items // Merge optimistic items
const items = getOptimistic(sessionID) const items = getOptimistic(sessionID)
@@ -234,13 +310,13 @@ export function useSync() {
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" }, { skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
) )
store.setState({ message: materialized.message, part: materialized.part })
setMetaFor(sessionID, { setMetaFor(sessionID, {
limit: materialized.messages.length, limit: materialized.messages.length,
cursor: merged.cursor, cursor: merged.cursor,
complete: merged.complete, complete: merged.complete,
loading: false, loading: false,
}) })
store.setState({ message: materialized.message, part: materialized.part })
setSessionPrefetch({ setSessionPrefetch({
directory, directory,
sessionID, sessionID,
@@ -269,16 +345,32 @@ export function useSync() {
const m = getMetaFor(sessionID) const m = getMetaFor(sessionID)
const materialization = getSessionMaterializationStatus(current, sessionID) const materialization = getSessionMaterializationStatus(current, sessionID)
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0 const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined
const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0)
const needsVSCodeInitialTurnBoundary = isVSCodeRuntime()
&& cached
&& !hasUserMessage(current.message[sessionID])
&& knownCachedLimit < getVSCodeInitialPageExpansionMax()
&& !m.complete
&& prefetchInfo?.complete !== true
&& Boolean(m.cursor ?? prefetchInfo?.cursor)
if (needsVSCodeInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) {
setMetaFor(sessionID, {
limit: prefetchInfo.limit,
cursor: prefetchInfo.cursor,
complete: prefetchInfo.complete,
})
}
const cachedReady = cached && !needsVSCodeInitialTurnBoundary
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
if (cached && hasSession && !force) return if (cachedReady && hasSession && !force) return
// Skip if recently fetched (TTL) // Skip if recently fetched (TTL)
if (!force) { if (!force && !needsVSCodeInitialTurnBoundary) {
const prefetchInfo = getSessionPrefetch(directory, sessionID)
if (shouldSkipSessionPrefetch({ if (shouldSkipSessionPrefetch({
hasMessages: cached, hasMessages: cachedReady,
info: prefetchInfo, info: prefetchInfo,
pageSize: MESSAGE_PAGE_SIZE, pageSize: getEffectiveMessagePageSize(),
})) return })) return
} }
@@ -304,7 +396,7 @@ export function useSync() {
} }
// Load messages if needed // Load messages if needed
if (!cached || force) { if (!cachedReady || force) {
await loadMessages(sessionID) await loadMessages(sessionID)
} }
})() })()
@@ -313,7 +405,7 @@ export function useSync() {
promise.finally(() => inflight.current.delete(key)) promise.finally(() => inflight.current.delete(key))
return promise return promise
}, },
[store, sdk, keyFor, touch, getMetaFor, loadMessages, directory], [store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],
) )
// Load more (pagination) // Load more (pagination)
+45 -5
View File
@@ -23,7 +23,23 @@ type ApiSessionMessageRequestPayload = {
type ApiProxyResponsePayload = { type ApiProxyResponsePayload = {
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
bodyBase64: string; bodyBase64?: string;
bodyText?: string;
};
const shouldReturnTextBody = (headers: Headers): boolean => {
const contentType = headers.get('content-type')?.toLowerCase() || '';
return contentType.startsWith('application/json')
|| contentType.startsWith('text/')
|| contentType.includes('+json');
};
const collectProxyResponseHeaders = (headers: Headers, deps: Pick<ProxyRuntimeDeps, 'collectHeaders'>): Record<string, string> => {
const result = deps.collectHeaders(headers);
delete result['content-length'];
delete result['content-encoding'];
delete result['transfer-encoding'];
return result;
}; };
type ProxyRuntimeDeps = { type ProxyRuntimeDeps = {
@@ -88,10 +104,22 @@ export async function handleProxyBridgeMessage(
: undefined, : undefined,
}); });
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
if (shouldReturnTextBody(response.headers)) {
const bodyText = await response.text();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: responseHeaders,
bodyText,
};
return { id, type, success: true, data };
}
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const data: ApiProxyResponsePayload = { const data: ApiProxyResponsePayload = {
status: response.status, status: response.status,
headers: deps.collectHeaders(response.headers), headers: responseHeaders,
bodyBase64: Buffer.from(arrayBuffer).toString('base64'), bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
}; };
@@ -103,7 +131,7 @@ export async function handleProxyBridgeMessage(
const data: ApiProxyResponsePayload = { const data: ApiProxyResponsePayload = {
status: 502, status: 502,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
bodyBase64: deps.base64EncodeUtf8(body), bodyText: body,
}; };
return { id, type, success: true, data }; return { id, type, success: true, data };
} }
@@ -149,10 +177,22 @@ export async function handleProxyBridgeMessage(
signal: AbortSignal.timeout(45000), signal: AbortSignal.timeout(45000),
}); });
const responseHeaders = collectProxyResponseHeaders(response.headers, deps);
if (shouldReturnTextBody(response.headers)) {
const bodyText = await response.text();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: responseHeaders,
bodyText,
};
return { id, type, success: true, data };
}
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const data: ApiProxyResponsePayload = { const data: ApiProxyResponsePayload = {
status: response.status, status: response.status,
headers: deps.collectHeaders(response.headers), headers: responseHeaders,
bodyBase64: Buffer.from(arrayBuffer).toString('base64'), bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
}; };
@@ -168,7 +208,7 @@ export async function handleProxyBridgeMessage(
const data: ApiProxyResponsePayload = { const data: ApiProxyResponsePayload = {
status: isTimeout ? 504 : 503, status: isTimeout ? 504 : 503,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
bodyBase64: deps.base64EncodeUtf8(body), bodyText: body,
}; };
return { id, type, success: true, data }; return { id, type, success: true, data };
} }
+2 -1
View File
@@ -107,7 +107,8 @@ export function sendBridgeMessageWithOptions<T = unknown>(
export type ProxiedApiResponse = { export type ProxiedApiResponse = {
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
bodyBase64: string; bodyBase64?: string;
bodyText?: string;
}; };
export async function proxyApiRequest(options: { export async function proxyApiRequest(options: {
+5 -1
View File
@@ -327,12 +327,16 @@ const decodeBase64 = (value: string): Uint8Array => {
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304; const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
const buildProxiedResponse = ( const buildProxiedResponse = (
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string } proxied: { status: number; headers: Record<string, string>; bodyBase64?: string; bodyText?: string }
): Response => { ): Response => {
if (isNullBodyStatus(proxied.status)) { if (isNullBodyStatus(proxied.status)) {
return new Response(null, { status: proxied.status, headers: proxied.headers }); return new Response(null, { status: proxied.status, headers: proxied.headers });
} }
if (typeof proxied.bodyText === 'string') {
return new Response(proxied.bodyText, { status: proxied.status, headers: proxied.headers });
}
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
return new Response(body, { status: proxied.status, headers: proxied.headers }); return new Response(body, { status: proxied.status, headers: proxied.headers });
}; };