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
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import React from 'react';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
|
import { useGitAllBranches } from '@/stores/useGitStore';
|
|
import type { SessionNode } from '../types';
|
|
import { compareSessionsByPinnedAndTime, isPathWithinProject } from '../utils';
|
|
|
|
export type SwitcherItem = {
|
|
node: SessionNode;
|
|
projectId: string | null;
|
|
groupDirectory: string | null;
|
|
secondaryMeta: {
|
|
projectLabel?: string | null;
|
|
branchLabel?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
const MAX_PARENT_SESSIONS = 7;
|
|
|
|
type SwitcherItemsOptions = {
|
|
scopeProjectId?: string | null;
|
|
};
|
|
|
|
const normalize = (value: string | null | undefined): string | null => {
|
|
if (!value) return null;
|
|
const replaced = value.replace(/\\/g, '/');
|
|
if (replaced === '/') return '/';
|
|
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
|
};
|
|
|
|
const formatProjectLabel = (project: { label?: string | null; path: string } | null): string | null => {
|
|
if (!project) return null;
|
|
const trimmed = project.label?.trim();
|
|
if (trimmed) return trimmed;
|
|
const segments = project.path.split(/[\\/]/).filter(Boolean);
|
|
return segments[segments.length - 1] ?? null;
|
|
};
|
|
|
|
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
|
const { scopeProjectId = null } = options;
|
|
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
|
const branchesByDirectory = useGitAllBranches();
|
|
|
|
const normalizedProjects = React.useMemo(
|
|
() => projects
|
|
.map((project) => ({ ...project, normalizedPath: normalize(project.path) }))
|
|
.filter((project) => project.normalizedPath),
|
|
[projects],
|
|
);
|
|
|
|
const findProjectForDirectory = React.useCallback(
|
|
(directory: string | null) => {
|
|
if (!directory) return null;
|
|
const matches = normalizedProjects
|
|
.filter((project) => isPathWithinProject(directory, project.normalizedPath))
|
|
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
|
|
return matches[0] ?? null;
|
|
},
|
|
[normalizedProjects],
|
|
);
|
|
|
|
const items = React.useMemo<SwitcherItem[]>(() => {
|
|
if (!enabled) return [];
|
|
|
|
const childrenByParent = new Map<string, Session[]>();
|
|
for (const session of activeSessions) {
|
|
const parentId = (session as Session & { parentID?: string | null }).parentID;
|
|
if (!parentId) continue;
|
|
if (session.time?.archived) continue;
|
|
const bucket = childrenByParent.get(parentId);
|
|
if (bucket) {
|
|
bucket.push(session);
|
|
} else {
|
|
childrenByParent.set(parentId, [session]);
|
|
}
|
|
}
|
|
childrenByParent.forEach((list) => {
|
|
list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
|
});
|
|
|
|
const parents = activeSessions
|
|
.filter((session) => !session.time?.archived)
|
|
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
|
.filter((session) => {
|
|
if (!scopeProjectId) return true;
|
|
const directory = resolveGlobalSessionDirectory(session);
|
|
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
|
})
|
|
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))
|
|
.slice(0, MAX_PARENT_SESSIONS);
|
|
|
|
const buildNode = (session: Session): SessionNode => {
|
|
const childSessions = childrenByParent.get(session.id) ?? [];
|
|
return {
|
|
session,
|
|
children: childSessions.map((child) => buildNode(child)),
|
|
worktree: null,
|
|
};
|
|
};
|
|
|
|
return parents.map((session) => {
|
|
const directory = resolveGlobalSessionDirectory(session);
|
|
const matchedProject = findProjectForDirectory(directory);
|
|
const projectLabel = formatProjectLabel(matchedProject);
|
|
const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null;
|
|
return {
|
|
node: buildNode(session),
|
|
projectId: matchedProject?.id ?? null,
|
|
groupDirectory: directory,
|
|
secondaryMeta: {
|
|
projectLabel,
|
|
branchLabel: branchLabel && branchLabel !== projectLabel ? branchLabel : null,
|
|
},
|
|
};
|
|
});
|
|
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId]);
|
|
|
|
return items;
|
|
};
|