Files
openchamber/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts
T
bashrusakh 59ecd86b4b perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
  churn, and UI jank on large sessions and big session trees. Behavior is preserved;
  the changes are about *when* and *how much* the UI re-renders.

  ## Chat streaming
  - Freeze the streaming message's parts in the bulk turn projection during streaming,
    and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
    no longer re-runs the whole-session projection or re-renders unrelated rows.
    session with referential reuse of unchanged turns.
  - Memoize message rows with field-aware comparators instead of reference equality.
  - Replace the manual child-session polling in the task tool with the live SSE
    stream + a one-shot load, removing a fetch/settle state machine.

  ## History loading & scroll
  - Load an initial page fast, then prepend one older page in the background so the
    scroll container has headroom and "load older on scroll-up" fires before the user
    hits the absolute top.
  - Compensate scroll synchronously (in a layout effect, before paint) for prepends —
    including background prepends that don't originate from a user scroll — so the
    viewport stays stable instead of judder-correcting on the next frame.

  ## Markdown rendering
  - Render markdown synchronously *styled* on first paint (paragraphs, lists, code
    cards, tables, inline code) instead of raw escaped text; the async pass then only
    upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
  - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
    chunk, avoiding a late stylesheet injection on first render.

  ## Sidebar
  - Hoist per-row recursive tree walks out of row comparators into per-group
    precomputed sets/keys; batch live-session lookups into a single map; add a
    group-level memo boundary.
  - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.

  ## Sync layer
  - Add a staleness guard so a slow message fetch can't repopulate a session the user
    navigated away from.
  - Throw on fetch failure for authoritative loaders so a transient blip can't read as
    an empty server response.

  ## Cleanup
  - Remove dead code (unused hooks, params, duplicated inline types) surfaced while
    reworking the above.

  ## Known issue
  - A rare, purely cosmetic first-paint width flash can still appear on large sessions;
    it has no behavioral or data impact and is tracked for a follow-up runtime trace.
2026-06-18 00:43:16 +03:00

174 lines
5.4 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
import { normalizePath } from '../utils';
import type { MainTab } from '@/stores/useUIStore';
import { useUIStore } from '@/stores/useUIStore';
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
type Args = {
projectSections: ProjectSection[];
activeProjectId: string | null;
activeSessionByProject: Map<string, string>;
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
newSessionDraftOpen: boolean;
mobileVariant: boolean;
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
};
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
} = args;
const projectSessionMeta = React.useMemo(() => {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = normalizePath(
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot,
);
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}, [projectSections]);
const previousActiveProjectRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
if (!activeProjectId) {
return;
}
if (newSessionDraftOpen) {
return;
}
if (useUIStore.getState().isNewWorktreeDialogOpen) {
return;
}
if (previousActiveProjectRef.current === activeProjectId) {
return;
}
const section = projectSections.find((item) => item.project.id === activeProjectId);
if (!section) {
return;
}
previousActiveProjectRef.current = activeProjectId;
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
}
const next = new Map(prev);
next.set(activeProjectId, currentSessionId);
return next;
});
return;
}
if (!projectMap || projectMap.size === 0) {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft({ directoryOverride: section.project.normalizedPath });
return;
}
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
const targetSessionId = remembered ?? fallback;
if (!targetSessionId || targetSessionId === currentSessionId) {
return;
}
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
handleSessionSelect(targetSessionId, targetDirectory, activeProjectId);
}, [
activeProjectId,
activeSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
projectSections,
projectSessionMeta,
setActiveMainTab,
setSessionSwitcherOpen,
setActiveSessionByProject,
]);
React.useEffect(() => {
if (!activeProjectId || !currentSessionId) {
return;
}
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (!projectMap || !projectMap.has(currentSessionId)) {
return;
}
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
}
const next = new Map(prev);
next.set(activeProjectId, currentSessionId);
return next;
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
};