perf(chat): make session switching feel instant

Switching sessions ran as one synchronous commit: sidebar highlight, URL,
a full timeline remount with markdown re-parse, and around nine requests,
so nothing changed on screen for 150-250ms after the click.

- ChatContainer swaps the timeline on a deferred copy of the selection, so
  the active row, URL, and tab commit first and the timeline renders behind
  them; selection policy keeps reading the live store value.
- The message fetch starts before the selection is published.
- Sidebar rows stop re-rendering on a project switch: directory-scoped sync
  hooks read the runtime context and a subscribable current-directory source
  instead of the directory-bearing context; the grouping builder reads git
  branches through a ref and section caches key the branches they use;
  descendant ids are keyed by content. Rows per switch went from 73 to 8.
- Markdown skips the async re-render when the settled cached blocks are
  already painted, and mounts synchronously once its lazy module is loaded;
  the module is preloaded at boot.
- A timeline reveal gate holds a freshly opened session at opacity 0 while
  any provisional markdown paint catches up (250ms cap), then fades the whole
  timeline in once, so text, tools, and recap appear together.
- Switch fan-out trimmed: knowledge summary deduped, MCP status refreshed only
  when stale, non-repo directories cached by the git repo check, OpenChamber
  defaults cached briefly, agent memory reused for the same project, goal
  text cached, PWA manifest rebuilt after the switch settles.
- Header tabs snap into the active state and keep the title at the same
  height in both states.
- Prefetch on row press; composer focus moved off the commit.

`bun run profile:switch` records ack/content latency, longest task, and
requests per switch, cold and warm, and compares runs against a baseline.
Measured warm switch: ack 228ms to about 40-60ms, content 228ms to about
100-120ms.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 17:01:15 +03:00
parent 123c14260a
commit edfc9779cf
32 changed files with 947 additions and 118 deletions
@@ -210,7 +210,7 @@ command, how to stand up a production build to measure against, how to read the
artifacts, and the validity guarantees these scripts enforce. Read it before
measuring.
Four unattended capture commands exist; prefer them over ad-hoc timing code,
Five unattended capture commands exist; prefer them over ad-hoc timing code,
and extend them when a scenario is missing rather than measuring by hand.
| Command | Answers |
@@ -218,6 +218,8 @@ and extend them when a scenario is missing rather than measuring by hand.
| `bun run profile:idle` | What the app does while nobody interacts with it. Supports `--session`, `--tab`, `--then-tab`, `--panel`, `--expand-projects` to reach a specific mounted state, plus `--baseline` and `--budget-*` for regression gating. |
| `bun run profile:session` | What a streaming assistant response costs. Creates a session, dispatches a prompt through the `openchamber session` CLI, and records until the session reports idle. Reports the long-task distribution, a timeline-trace breakdown, running animations, and output-normalised metrics. |
| `bun run profile:animation` | What a CSS animation costs, isolated from the app. Animate only `transform` and `opacity`; everything else recalculates style every frame. |
| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. |
| `bun run profile:switch` | How long switching sessions from the sidebar takes: `ack` (the clicked row highlights) and `content` (the target session's messages are on screen), cold and warm, plus the requests each switch fires. Use it as the regression gate for any change in the sidebar, header, chat container, or markdown first paint. |
| `bun run profile:browser` | A manually driven capture when the interaction cannot be scripted. |
Both automated commands fail loudly rather than reporting a clean result when
+2 -1
View File
@@ -87,7 +87,8 @@
"release:test:arm": "./scripts/test-release-build.sh aarch64",
"profile:idle": "node scripts/profile-idle.mjs",
"profile:session": "node scripts/profile-session.mjs",
"profile:animation": "node scripts/profile-animation.mjs"
"profile:animation": "node scripts/profile-animation.mjs",
"profile:switch": "node scripts/profile-switch.mjs"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import { useGlobalSyncStore } from '@/sync/global-sync-store';
import MessageList, { type MessageListHandle } from './MessageList';
import { createTimelineRevealGate, TIMELINE_REVEAL_CAP_MS, TimelineRevealGateContext } from './timelineRevealGate';
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
@@ -175,9 +176,6 @@ type ChatViewportProps = {
} | null;
scrollToBottom: () => void;
endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton;
// cached sessions render instantly without it.
revealContent: boolean;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -214,7 +212,6 @@ const ChatViewport = React.memo(({
retryOverlay,
scrollToBottom,
endPinningReleased,
revealContent,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -368,6 +365,45 @@ const ChatViewport = React.memo(({
</>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
// Opening a session paints the timeline as one finished picture: the root
// stays invisible while any renderer holds a provisional first paint, then
// everything appears together. A warm switch, where nothing is held,
// reveals in the same frame; a cold open fades in once as a whole.
const timelineRootRef = React.useRef<HTMLDivElement | null>(null);
const revealGate = React.useMemo(() => createTimelineRevealGate(), [currentSessionKey]);
React.useLayoutEffect(() => {
const root = timelineRootRef.current;
if (!root) return;
root.setAttribute('data-timeline-reveal', 'pending');
let finished = false;
let timer: number | null = null;
const reveal = (fade: boolean) => {
if (finished) return;
finished = true;
if (timer !== null) window.clearTimeout(timer);
if (fade) root.setAttribute('data-timeline-reveal', 'fading');
else root.removeAttribute('data-timeline-reveal');
};
// Holds are taken in layout effects, including those of rows the list
// mounts in a nested synchronous pass; a microtask runs after all of
// them and still before the browser paints this commit.
queueMicrotask(() => {
if (finished) return;
revealGate.close();
if (revealGate.holds === 0) {
reveal(false);
return;
}
revealGate.onEmpty = () => reveal(true);
timer = window.setTimeout(() => reveal(true), TIMELINE_REVEAL_CAP_MS);
});
return () => {
finished = true;
if (timer !== null) window.clearTimeout(timer);
revealGate.onEmpty = null;
};
}, [revealGate]);
const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
style: CHAT_SCROLL_STYLE,
@@ -385,11 +421,12 @@ const ChatViewport = React.memo(({
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)}
ref={timelineRootRef}
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0">
<TimelineRevealGateContext.Provider value={revealGate}>
<MessageList
key={currentSessionKey}
ref={messageListRef}
@@ -417,6 +454,7 @@ const ChatViewport = React.memo(({
listFooter={listFooter}
scrollContainerProps={scrollContainerProps}
/>
</TimelineRevealGateContext.Provider>
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
@@ -448,7 +486,6 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay
&& prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent
&& prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -824,9 +861,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -1163,15 +1197,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating =
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1310,9 +1335,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
}
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') {
return (
@@ -1415,7 +1437,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
retryOverlay={retryOverlay}
scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}
+13 -9
View File
@@ -72,8 +72,8 @@ import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { selectCommandsForDirectory, useCommandsStore } from '@/stores/useCommandsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
@@ -602,8 +602,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// Known slash-invocations (commands + skills + built-ins) used to highlight
// matching /tokens in the composer, the same way confirmed @files are.
const availableCommands = useCommandsStore((s) => s.commands);
const availableSkills = useSkillsStore((s) => s.skills);
const availableCommands = useCommandsStore((s) => selectCommandsForDirectory(s, currentDirectory));
const availableSkills = useSkillsStore((s) => selectSkillsForDirectory(s, currentDirectory));
const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
@@ -1140,7 +1140,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
: [];
const availableSkillNames = new Set(
useSkillsStore.getState().skills.map((skill) => skill.name),
selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
);
const outgoing = buildOutgoingMessage({
@@ -2262,10 +2262,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
};
React.useEffect(() => {
if (active && currentSessionId && composerRef.current && !isMobile) {
composerRef.current.focus();
}
if (!active || !currentSessionId || isMobile) return;
// Focusing forces layout. Right after a session switch the layout is
// dirty from the whole timeline mounting, so the focus call would pay
// for that layout inside the commit; a frame later it is nearly free.
const frame = window.requestAnimationFrame(() => {
composerRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [active, currentSessionId, isMobile]);
React.useEffect(() => {
@@ -2,7 +2,7 @@ import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { cn } from '@/lib/utils';
import { loadMarkdownRendererModule } from './markdownRendererLoader';
import { getLoadedMarkdownRendererModule, loadMarkdownRendererModule } from './markdownRendererLoader';
// Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -41,21 +41,29 @@ const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown;
);
};
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
<MarkdownRendererLazy {...props} />
</React.Suspense>
);
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => {
const loaded = getLoadedMarkdownRendererModule();
if (loaded) return <loaded.MarkdownRenderer {...props} />;
return (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
<MarkdownRendererLazy {...props} />
</React.Suspense>
);
};
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
fallbackContent?: React.ReactNode;
};
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => {
const loaded = getLoadedMarkdownRendererModule();
if (loaded) return <loaded.SimpleMarkdownRenderer {...props} />;
return (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
};
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}>
@@ -193,6 +193,8 @@ const fakeReact = {
return hookStates[index] as { current: T };
},
memo: <T>(component: T): T => component,
createContext: <T>(defaultValue: T) => ({ Provider: 'provider', defaultValue }),
useContext: <T>(context: { defaultValue: T }): T => context.defaultValue,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
@@ -51,6 +51,7 @@ import {
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { TimelineRevealGateContext } from './timelineRevealGate';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
@@ -692,6 +693,30 @@ const useMermaidInlineInteractions = ({
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
// True when the container already holds exactly these settled blocks with the
// current decoration. The first paint of a remounted message is served from
// the block cache; when that paint is already final, the async render would
// only parse, highlight, sanitize, and morph the same HTML into place again.
const domMatchesRenderedBlocks = (
target: HTMLElement,
blocks: ReadonlyArray<{ id: string }>,
decorationId: string,
): boolean => {
const children = target.children;
if (children.length !== blocks.length) return false;
for (let index = 0; index < blocks.length; index += 1) {
const child = children[index];
if (
!child
|| child.getAttribute('data-md-id') !== blocks[index]?.id
|| child.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId
) {
return false;
}
}
return true;
};
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
@@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
// A provisional first paint (blocks not in the settled cache) holds the
// timeline reveal until the async render lands, so the session opens with
// final code highlighting instead of a visible restyle.
const revealGate = React.useContext(TimelineRevealGateContext);
const releaseRevealHoldRef = React.useRef<(() => void) | null>(null);
const releaseRevealHold = React.useCallback(() => {
releaseRevealHoldRef.current?.();
releaseRevealHoldRef.current = null;
}, []);
React.useEffect(() => releaseRevealHold, [releaseRevealHold]);
// Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
@@ -909,6 +944,9 @@ const useMorphdomMarkdown = ({
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
if (!streaming && !releaseRevealHoldRef.current) {
releaseRevealHoldRef.current = revealGate?.hold() ?? null;
}
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
@@ -939,6 +977,18 @@ const useMorphdomMarkdown = ({
const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
if (!streaming) {
const cachedBlocks = getCachedMarkdownBlocks(text, imageMode);
if (cachedBlocks && domMatchesRenderedBlocks(target, cachedBlocks, decorationId)) {
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
releaseRevealHold();
return;
}
}
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -1028,12 +1078,13 @@ const useMorphdomMarkdown = ({
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
releaseRevealHold();
});
return () => {
active = false;
};
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1,13 +1,31 @@
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null;
type MarkdownRendererModule = typeof import('./MarkdownRendererImpl');
let markdownRendererModulePromise: Promise<MarkdownRendererModule> | null = null;
let markdownRendererModule: MarkdownRendererModule | null = null;
export const loadMarkdownRendererModule = () => {
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
markdownRendererModulePromise = null;
throw error;
});
markdownRendererModulePromise ??= import('./MarkdownRendererImpl')
.then((module) => {
markdownRendererModule = module;
return module;
})
.catch((error) => {
markdownRendererModulePromise = null;
throw error;
});
return markdownRendererModulePromise;
};
/**
* The module once it has loaded, so a renderer can mount synchronously instead
* of suspending. A lazy component that suspends even on an already-resolved
* promise shows its fallback for a tick, and React then throttles the reveal
* of every boundary that resolves in the following ~300ms, which is how a
* freshly opened session showed user text first and assistant text a third of
* a second later.
*/
export const getLoadedMarkdownRendererModule = () => markdownRendererModule;
export const preloadMarkdownRenderer = () => {
void loadMarkdownRendererModule().catch(() => undefined);
};
@@ -0,0 +1,54 @@
import React from 'react';
/**
* Coordinates the first paint of a freshly opened session so the timeline
* appears as one finished picture instead of arriving in pieces.
*
* Renderers that mount with a provisional paint (markdown whose blocks are not
* in the settled cache yet, so code is unhighlighted) take a hold while they
* catch up. The timeline stays invisible while any hold is open, then reveals
* everything at once. The gate accepts holds only during the opening commit:
* rows that mount later, while scrolling, must never hide the timeline.
*
* A hold that never releases must not hide the chat forever, so the owner
* reveals after `TIMELINE_REVEAL_CAP_MS` regardless.
*/
type TimelineRevealGate = {
/** Take a hold; returns the release. Returns null once the gate is closed. */
hold: () => (() => void) | null;
/** Stops accepting holds. Existing holds still count. */
close: () => void;
readonly holds: number;
/** Called when the last hold releases, if the gate is closed by then. */
onEmpty: (() => void) | null;
};
export const TIMELINE_REVEAL_CAP_MS = 250;
export const createTimelineRevealGate = (): TimelineRevealGate => {
let holds = 0;
let accepting = true;
const gate: TimelineRevealGate = {
hold: () => {
if (!accepting) return null;
holds += 1;
let released = false;
return () => {
if (released) return;
released = true;
holds -= 1;
if (holds === 0 && !accepting) gate.onEmpty?.();
};
},
close: () => {
accepting = false;
},
get holds() {
return holds;
},
onEmpty: null,
};
return gate;
};
export const TimelineRevealGateContext = React.createContext<TimelineRevealGate | null>(null);
@@ -13,6 +13,8 @@ type Props = {
directory: string | null;
};
const MCP_STATUS_MAX_AGE_MS = 60_000;
/**
* MCP servers with their connection switches, reusing the dropdown's own
* connect/disconnect actions.
@@ -23,17 +25,19 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
const refreshMcp = useMcpStore((state) => state.refresh);
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
// opened to know its MCP servers. Silent and background-gated, so it cannot
// compete with chat bootstrap traffic for sockets.
// compete with chat bootstrap traffic for sockets. The section remounts on
// every session switch, so it only asks for a status that is missing or
// older than a minute; connect/disconnect/auth refresh on their own.
React.useEffect(() => {
void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true }));
}, [directory, refreshMcp]);
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
}, [directory, ensureMcpFresh]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
@@ -159,8 +159,11 @@ const SessionTabItem: React.FC<{
}}
data-controls-open={overlayVisible ? 'true' : 'false'}
className={cn(
// No color transition: activation must snap. A crossfade
// here reads as the switch itself being slow, since the
// old and new tab trade colors over several frames right
// after the click.
'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
'transition-colors duration-75',
isActive
? 'bg-interactive-selection'
: cn(
@@ -180,8 +183,15 @@ const SessionTabItem: React.FC<{
!suppressControls && 'session-tab-title',
)}
>
{/* Same box as the active content the header renders
(a centered column with a block title), so the
title sits at the same height before and after
activation and does not jump when the tab swaps
its content. */}
{isActive ? children : (
<span className="text-[13px] font-medium leading-4">{title}</span>
<div className="flex min-w-0 flex-col justify-center">
<span className="block max-w-full overflow-hidden whitespace-nowrap text-[13px] font-medium leading-4">{title}</span>
</div>
)}
</div>
{showDot ? (
@@ -232,6 +232,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
availableWorktreesByProject: topology.availableWorktreesByProject,
projectRepoStatus: topology.projectRepoStatus,
projectRootBranches: topology.projectRootBranches,
gitBranches: topology.gitBranches,
lastRepoStatus: topology.lastRepoStatus,
buildGroupedSessions,
hasSessionSearchQuery: view.hasSessionSearchQuery,
@@ -28,6 +28,12 @@ const isArchivedSession = (session: Session): boolean => Boolean(session.time?.a
export const useSessionGrouping = (args: Args) => {
const { t } = useI18n();
// Read at call time rather than captured: the branch map is rebuilt whenever
// any directory's git status changes, and a builder that changed identity
// with it would invalidate every project section in the sidebar. The section
// cache compares the branches each project actually uses instead.
const gitBranchesRef = React.useRef(args.gitBranches);
gitBranchesRef.current = args.gitBranches;
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
}, []);
@@ -233,7 +239,7 @@ export const useSessionGrouping = (args: Args) => {
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
worktreeGroups.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
const currentBranch = gitBranchesRef.current.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
@@ -274,7 +280,7 @@ export const useSessionGrouping = (args: Args) => {
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.isVSCode, t],
);
return {
@@ -55,6 +55,7 @@ const renderSections = (group: SessionGroup, query: string): Sections => {
availableWorktreesByProject: new Map(),
projectRepoStatus: new Map(),
projectRootBranches: new Map(),
gitBranches: new Map(),
lastRepoStatus: false,
buildGroupedSessions: grouping.buildGroupedSessions,
hasSessionSearchQuery: query.length > 0,
@@ -29,11 +29,23 @@ type ProjectSectionCacheEntry = {
archivedSessions: Session[];
availableWorktrees: WorktreeMetadata[];
rootBranch: string | null;
/** Current branch of every worktree directory the section renders. */
worktreeBranchesKey: string;
isRepo: boolean;
buildGroupedSessions: Args['buildGroupedSessions'];
section: ProjectSection;
};
const worktreeBranchesKeyFor = (
worktrees: WorktreeMetadata[],
gitBranches: ReadonlyMap<string, string | null>,
): string => worktrees
.map((worktree) => {
const directory = normalizePath(worktree.path) ?? worktree.path;
return `${directory}=${gitBranches.get(directory) ?? ''}`;
})
.join('\n');
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
type Args = {
@@ -43,6 +55,7 @@ type Args = {
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
projectRepoStatus: Map<string, boolean | null>;
projectRootBranches: Map<string, string | null>;
gitBranches: ReadonlyMap<string, string | null>;
lastRepoStatus: boolean;
buildGroupedSessions: (
sessions: Session[],
@@ -73,6 +86,7 @@ export const useSessionSidebarSections = (args: Args) => {
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
gitBranches,
lastRepoStatus,
buildGroupedSessions,
hasSessionSearchQuery,
@@ -101,6 +115,7 @@ export const useSessionSidebarSections = (args: Args) => {
? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatus;
const rootBranch = projectRootBranches.get(project.id) ?? null;
const worktreeBranchesKey = worktreeBranchesKeyFor(worktreesForProject, gitBranches);
const cached = previousCache.get(project.id);
if (
cached
@@ -109,6 +124,7 @@ export const useSessionSidebarSections = (args: Args) => {
&& sameSessions(cached.archivedSessions, archivedSessions)
&& cached.availableWorktrees === worktreesForProject
&& cached.rootBranch === rootBranch
&& cached.worktreeBranchesKey === worktreeBranchesKey
&& cached.isRepo === isRepo
&& cached.buildGroupedSessions === buildGroupedSessions
) {
@@ -118,6 +134,19 @@ export const useSessionSidebarSections = (args: Args) => {
}
rebuiltSections += 1;
if (cached) {
// Diagnostic: name what invalidated the cached section so a sidebar
// that rebuilds on every session switch can be traced to its input.
const reason = cached.project !== project ? 'project'
: !sameSessions(cached.activeSessions, activeSessions) ? 'sessions'
: !sameSessions(cached.archivedSessions, archivedSessions) ? 'archived'
: cached.availableWorktrees !== worktreesForProject ? 'worktrees'
: cached.rootBranch !== rootBranch ? 'branch'
: cached.worktreeBranchesKey !== worktreeBranchesKey ? 'worktreeBranches'
: cached.isRepo !== isRepo ? 'repo'
: 'builder';
streamPerfCount(`ui.sidebar.project_section.rebuilt_reason.${reason}`);
}
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
const groups = buildGroupedSessions(
projectSessions,
@@ -133,6 +162,7 @@ export const useSessionSidebarSections = (args: Args) => {
archivedSessions,
availableWorktrees: worktreesForProject,
rootBranch,
worktreeBranchesKey,
isRepo,
buildGroupedSessions,
section,
@@ -152,6 +182,7 @@ export const useSessionSidebarSections = (args: Args) => {
lastRepoStatus,
buildGroupedSessions,
projectRootBranches,
gitBranches,
]);
const visibleProjectSections = React.useMemo(() => {
@@ -23,7 +23,8 @@ import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { usePrefetchSessionMessages, useSessionMessageRecordsForExport } from '@/sync/use-sync';
import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils';
@@ -406,6 +407,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
// selection must survive mixing sessions from different worktrees.
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
const loadExportRecords = useSessionMessageRecordsForExport();
const prefetchSessionMessages = usePrefetchSessionMessages();
// Same gate as the sidebar's neighbor prefetch: the VS Code webview keeps
// its message traffic to what is actually opened.
const prefetchOnPressDisabled = isVSCode;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const isRowSelected = useSessionMultiSelectStore(
@@ -908,6 +913,20 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
if (mobileVariant && event.pointerType === 'touch') {
setIsTouchPressed(true);
}
// The press is the earliest signal that this row is about to be opened.
// Starting the message load here puts the request on the wire before the
// click handler and the render it triggers, so a cold open overlaps the
// network round trip with that work instead of waiting for it.
if (
event.button === 0
&& !isActive
&& !selectionModeEnabled
&& !prefetchOnPressDisabled
&& sessionDirectory
&& !getSyncSessionMaterializationStatus(session.id, sessionDirectory).renderable
) {
void prefetchSessionMessages({ directory: sessionDirectory, sessionID: session.id }).catch(() => undefined);
}
};
const handleRowPointerEnd = (event: React.PointerEvent<HTMLButtonElement>) => {
if (mobileVariant && event.pointerType === 'touch') {
@@ -1334,6 +1353,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
data-session-row={session.id}
data-session-scope={selectionScopeKey ?? ''}
data-session-archived={archivedBucket ? '1' : '0'}
aria-current={isActive ? 'page' : undefined}
onClick={handleRowBackgroundClick}
// Row geometry mirrors the zone-header band: full container
// width, px-1.5 inner edge, a 14px icon-wide gutter (status
@@ -1707,24 +1727,27 @@ const areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean =
&& prev.time?.archived === next.time?.archived
);
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
if (prev.node.session.id !== next.node.session.id) return false;
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false;
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.archivedBucket !== next.archivedBucket) return false;
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
if (prev.relativeTimeTick !== next.relativeTimeTick) return false;
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
// Returns the name of the first prop whose change requires a render, or null
// when the row can skip it. The name feeds the stream perf counters so sidebar
// churn is explained, not only counted.
const sessionNodeItemPropsChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): string | null => {
if (prev.node.session.id !== next.node.session.id) return 'node';
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return 'node';
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return 'node';
if (prev.depth !== next.depth) return 'depth';
if (prev.groupDirectory !== next.groupDirectory) return 'groupDirectory';
if (prev.projectId !== next.projectId) return 'projectId';
if (prev.archivedBucket !== next.archivedBucket) return 'archivedBucket';
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return 'renderContext';
if (prev.mobileVariant !== next.mobileVariant) return 'mobileVariant';
if (prev.alwaysShowActions !== next.alwaysShowActions) return 'alwaysShowActions';
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return 'hasSessionSearchQuery';
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return 'normalizedSessionSearchQuery';
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return 'notifyOnSubtasks';
if (prev.nodeStructureKey !== next.nodeStructureKey) return 'nodeStructureKey';
if (prev.relativeTimeTick !== next.relativeTimeTick) return 'relativeTimeTick';
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return 'nodeDirectory';
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return 'secondaryMeta';
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& nodeHasPinnedMembershipChange(
@@ -1735,11 +1758,11 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
prev.groupDirectory,
next.groupDirectory,
)) {
return false;
return 'pinnedSessionIds';
}
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
return false;
return 'expandedParents';
}
if (prev.editingId !== next.editingId
@@ -1747,7 +1770,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
return 'editingId';
}
if (prev.editTitle !== next.editTitle
@@ -1755,7 +1778,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
return 'editTitle';
}
if (prev.copiedSessionId !== next.copiedSessionId
@@ -1763,18 +1786,18 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId)
)) {
return false;
return 'copiedSessionId';
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
return 'openSidebarMenuKey';
}
}
return prev.setEditingId === next.setEditingId
const callbacksEqual = prev.setEditingId === next.setEditingId
&& prev.setEditTitle === next.setEditTitle
&& prev.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit
@@ -1791,6 +1814,15 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
&& prev.handleRestoreSession === next.handleRestoreSession
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
&& prev.children === next.children;
if (!callbacksEqual) return 'callbacks';
return null;
};
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
const changed = sessionNodeItemPropsChange(prev, next);
if (changed === null) return true;
streamPerfCount(`ui.sidebar_session_node.props_changed.${changed}`);
return false;
};
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
@@ -97,15 +97,23 @@ export function SessionTreeItem({
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const descendantIds = React.useMemo(() => {
// Keyed by the descendant ids themselves, not by node identity: the sidebar
// rebuilds a project's node tree whenever one of its session records
// changes, and a fresh array here would give every row in that project a
// new delete handler and force it to re-render.
const descendantIdsKey = React.useMemo(() => {
const ids: string[] = [];
const visit = (current: SessionNode) => current.children.forEach((child) => {
ids.push(child.session.id);
visit(child);
});
visit(node);
return ids;
return ids.join('\n');
}, [node]);
const descendantIds = React.useMemo(
() => (descendantIdsKey ? descendantIdsKey.split('\n') : []),
[descendantIdsKey],
);
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
if (!scopeKey) return null;
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
+6 -1
View File
@@ -23,6 +23,8 @@ import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
* because this runs above `SyncProvider` that hook reads the sync context and
* throws outside it, which took the whole app down with a blank window.
*/
const AGENT_MEMORY_FRESH_MS = 60_000;
export const useAgentMemorySync = (directory: string | null): void => {
const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
@@ -31,11 +33,14 @@ export const useAgentMemorySync = (directory: string | null): void => {
const owner = useProjectContextOwner(directory);
const projectPath = owner?.path ?? null;
// The owner re-resolves on every directory switch; entries loaded moments
// ago for the same project are still current, and the change event below
// forces a re-read when the agent writes memory.
React.useEffect(() => {
if (!enabled) {
return;
}
void load(projectPath);
void load(projectPath, { maxAgeMs: AGENT_MEMORY_FRESH_MS });
}, [enabled, load, projectPath]);
// The agent writes memory mid-turn through its own tool, so the index for the
+9 -2
View File
@@ -14,6 +14,7 @@ type ManifestSyncWindow = Window & {
};
const MAX_RECENT_SHORTCUTS = 3;
const MANIFEST_UPDATE_DELAY_MS = 2_000;
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
if (typeof value !== 'string') {
@@ -86,7 +87,13 @@ export const usePwaManifestSync = () => {
return;
}
const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
// Rebuilding the manifest fetches it from the server. Shortcuts only
// matter to the installed-app menu, so the rebuild waits until the switch
// that changed them has settled instead of adding a request to it.
const timer = window.setTimeout(() => {
const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
}, MANIFEST_UPDATE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [hasRecentShortcuts, signature]);
};
+15 -1
View File
@@ -22,6 +22,9 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo
};
}
const OBJECTIVE_CONTENT_CACHE_MAX = 64;
const objectiveContentByFetchKey = new Map<string, Promise<string | null>>();
// Effective objective text for display. Inline goals return the metadata
// text directly; file-backed goals fetch the server-side file once per
// goal edit (keyed by id + updatedAt). Display-only: a failed fetch yields
@@ -37,7 +40,18 @@ export function useGoalObjectiveContent(sessionId: string, goal: SessionGoalPayl
return undefined;
}
let alive = true;
void fetchGoalObjectiveContent(sessionId).then((content) => {
// The key already names the goal edit, so a remount (every session switch
// remounts the strip) reuses the text instead of fetching the file again.
let request = objectiveContentByFetchKey.get(fetchKey);
if (!request) {
request = fetchGoalObjectiveContent(sessionId);
objectiveContentByFetchKey.set(fetchKey, request);
if (objectiveContentByFetchKey.size > OBJECTIVE_CONTENT_CACHE_MAX) {
const oldest = objectiveContentByFetchKey.keys().next().value;
if (oldest !== undefined) objectiveContentByFetchKey.delete(oldest);
}
}
void request.then((content) => {
if (alive) setFetched(content);
});
return () => {
+11 -2
View File
@@ -1389,12 +1389,21 @@ html:not(.dark) .chat-scroll {
}
}
.oc-chat-hydration-reveal {
.oc-chat-hydration-reveal,
[data-timeline-reveal='fading'] {
animation: oc-chat-hydration-reveal 180ms ease-out both;
}
/* Timeline root while a freshly opened session still has provisional first
paints (see timelineRevealGate.ts): hidden until every hold releases, then
revealed as a whole. */
[data-timeline-reveal='pending'] {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.oc-chat-hydration-reveal {
.oc-chat-hydration-reveal,
[data-timeline-reveal='fading'] {
animation: none;
}
}
Binary file not shown.
+6
View File
@@ -10,6 +10,7 @@ import './lib/debug'
import { syncDesktopSettings, initializeAppearancePreferences } from './lib/persistence'
import { startAppearanceAutoSave } from './lib/appearanceAutoSave'
import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence'
import { preloadMarkdownRenderer } from './components/chat/markdownRendererLoader'
import { startTypographyWatcher } from './lib/typographyWatcher'
import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave'
import { initializeLocale, I18nProvider } from './lib/i18n'
@@ -53,6 +54,11 @@ if (!rootElement) {
throw new Error('Root element not found');
}
// The first session opened after load renders its messages through the lazy
// markdown chunk; fetching it now, while the app boots, means that open shows
// text instead of empty message boxes until the chunk arrives.
preloadMarkdownRenderer();
createRoot(rootElement).render(
<StrictMode>
<I18nProvider>
+20 -3
View File
@@ -27,13 +27,19 @@ interface AgentMemoryState {
projectPath: string | null;
loading: boolean;
loaded: boolean;
/** When the held entries were last read successfully. */
loadedAt: number | null;
/** True once the server has reported the feature switched off. */
disabled: boolean;
globalFailed: boolean;
projectFailed: boolean;
error: string | null;
load: (projectPath: string | null) => Promise<void>;
/**
* `maxAgeMs` skips the read when the same project's entries were loaded
* more recently than that; omit it for an unconditional re-read.
*/
load: (projectPath: string | null, options?: { maxAgeMs?: number }) => Promise<void>;
/** Re-read the store the last load used. */
refresh: () => Promise<void>;
saveEntry: (
@@ -55,6 +61,7 @@ const EMPTY_STATE = {
globalFailed: false,
projectFailed: false,
error: null as string | null,
loadedAt: null as number | null,
};
const EMPTY_MEMORY: AgentMemoryEntry[] = [];
@@ -99,10 +106,19 @@ const errorMessage = (error: unknown, fallback: string): string => (
export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
...EMPTY_STATE,
load: async (projectPath) => {
const requestId = ++loadSequence;
load: async (projectPath, options) => {
const previous = get();
const ownerChanged = previous.projectPath !== projectPath;
if (
options?.maxAgeMs !== undefined
&& !ownerChanged
&& previous.loaded
&& previous.loadedAt !== null
&& Date.now() - previous.loadedAt < options.maxAgeMs
) {
return;
}
const requestId = ++loadSequence;
if (ownerChanged) {
set({ loading: true, projectPath, project: [], projectFailed: false });
} else {
@@ -120,6 +136,7 @@ export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
projectFailed: snapshot.projectFailed,
loading: false,
loaded: true,
loadedAt: Date.now(),
disabled: false,
error: null,
});
+20 -1
View File
@@ -67,7 +67,26 @@ interface OpenChamberDefaults {
sttLanguage?: string;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
// Directory activation re-reads the OpenChamber defaults, which are global,
// not per directory: one request serves the switches that land inside this
// window, and concurrent activations share the in-flight one.
const OPENCHAMBER_DEFAULTS_FRESH_MS = 15_000;
let openChamberDefaultsCache: { at: number; request: Promise<OpenChamberDefaults> } | null = null;
const fetchOpenChamberDefaults = (): Promise<OpenChamberDefaults> => {
const now = Date.now();
if (openChamberDefaultsCache && now - openChamberDefaultsCache.at < OPENCHAMBER_DEFAULTS_FRESH_MS) {
return openChamberDefaultsCache.request;
}
const request = requestOpenChamberDefaults();
openChamberDefaultsCache = { at: now, request };
request.catch(() => {
if (openChamberDefaultsCache?.request === request) openChamberDefaultsCache = null;
});
return request;
};
const requestOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
markStartupTrace('config.defaults:start');
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
const finish = (source: string, result: OpenChamberDefaults) => {
+5 -1
View File
@@ -639,8 +639,12 @@ export const useGitStore = create<GitStore>()(
try {
const now = Date.now();
// A known answer — repo or not — is cached for the stale window.
// Re-probing every non-repo directory (managed chats live in one)
// made each switch into such a directory cost a git check.
const shouldProbeRepository =
dirState.isGitRepo !== true ||
dirState.isGitRepo === null ||
dirState.isGitRepo === undefined ||
now - (dirState.lastRepoCheckAt || 0) > REPO_CHECK_STALE_THRESHOLD;
let isRepo = dirState.isGitRepo === true;
+25
View File
@@ -53,6 +53,8 @@ type RefreshOptions = {
silent?: boolean;
};
const ensureFreshInFlight = new Map<string, Promise<void>>();
type TestConnectionResult = {
status?: McpStatus;
error?: string;
@@ -64,11 +66,19 @@ interface McpStore {
diagnosticsByDirectory: Record<string, McpRuntimeDiagnosticMap>;
loadingKeys: Record<string, boolean>;
lastErrorKeys: Record<string, string | null>;
/** When each directory's status was last fetched successfully. */
refreshedAtKeys: Record<string, number>;
getStatusForDirectory: (directory?: string | null) => McpStatusMap;
getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap;
getErrorForDirectory: (directory?: string | null) => string | null;
refresh: (options?: RefreshOptions) => Promise<void>;
/**
* Refresh only when the directory has no status yet or the last successful
* fetch is older than `maxAgeMs`. Mount-time consumers use this so a panel
* that remounts on every session switch does not refetch on every switch.
*/
ensureFresh: (options: RefreshOptions & { maxAgeMs: number }) => Promise<void>;
connect: (name: string, directory?: string | null) => Promise<void>;
disconnect: (name: string, directory?: string | null) => Promise<void>;
startAuth: (name: string, directory?: string | null) => Promise<string>;
@@ -89,6 +99,7 @@ export const useMcpStore = create<McpStore>()(
diagnosticsByDirectory: {},
loadingKeys: {},
lastErrorKeys: {},
refreshedAtKeys: {},
getStatusForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
@@ -131,6 +142,7 @@ export const useMcpStore = create<McpStore>()(
},
loadingKeys: { ...state.loadingKeys, [key]: false },
lastErrorKeys: { ...state.lastErrorKeys, [key]: null },
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
@@ -141,6 +153,19 @@ export const useMcpStore = create<McpStore>()(
}
},
ensureFresh: async ({ maxAgeMs, ...options }) => {
const key = toKey(normalizeDirectory(options.directory ?? useDirectoryStore.getState().currentDirectory));
const refreshedAt = get().refreshedAtKeys[key];
if (refreshedAt !== undefined && Date.now() - refreshedAt < maxAgeMs) return;
const inFlight = ensureFreshInFlight.get(key);
if (inFlight) return inFlight;
const request = get().refresh(options).finally(() => {
ensureFreshInFlight.delete(key);
});
ensureFreshInFlight.set(key, request);
return request;
},
connect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(normalized);
+43
View File
@@ -413,6 +413,49 @@ The global stream can omit a directory for a session-addressed event. Resolve it
## Selector hygiene
### Runtime context versus directory context
`SyncProvider` publishes two contexts. `SyncRuntimeContext` (`useSyncRuntime()`)
holds the child-store manager, message loader, SDK, runtime key, and a
subscribable `currentDirectory` source; its value changes only on runtime
reconfiguration. `SyncContext` (`useSyncSystem()` / `useSync()`) adds the
current directory string, so every consumer re-renders on each directory
switch.
A hook that takes an explicit directory, or needs only runtime fields, must
read `useSyncRuntime()`. `useDirectoryStore(directory)` reads the current
directory through `runtime.currentDirectory` with `useSyncExternalStore`, so a
consumer that passes its own directory gets a constant snapshot and is not
re-rendered by a cross-project switch. This is what keeps sidebar rows
(permissions, question counts, session lookups) out of the switch commit: a
row must not pay for the chat changing directory.
### Session switch commit
The sidebar click publishes `currentSessionId`/`currentSessionDirectory`
synchronously, and the message fetch starts before that publication so the
request is on the wire while React renders. `ChatContainer` consumes a
`useDeferredValue` copy of the selection: the first commit paints the cheap
reactions (active row, URL, tabs) and the timeline for the new session renders
in a transition behind it. Selection *policy* inside `ChatContainer` (auto-
opening a draft when nothing is selected) reads the live store value, because
the deferred one still names the previous session for one commit.
The timeline's first paint for a session is atomic. `ChatContainer` owns a
`TimelineRevealGate` per session key (`components/chat/timelineRevealGate.ts`):
a markdown renderer whose first paint is provisional (blocks not yet in the
settled cache, so code is unhighlighted) takes a hold in its layout effect,
and the timeline root stays at opacity 0 until every hold releases, capped at
250ms, then fades in once as a whole. A warm switch takes no holds and reveals
in the same frame. The gate stops accepting holds after the opening commit so
rows mounting during scroll never hide the timeline. Once the lazy markdown
module has loaded, `MarkdownRenderer` mounts it synchronously instead of
through `Suspense`: a suspended boundary shows its fallback for a tick and
React then throttles later-resolving boundaries by ~300ms, which staggered
user and assistant text on a cold open.
`bun run profile:switch` measures both moments; see `scripts/perf/DOCUMENTATION.md`.
Select leaf values, not containers:
```typescript
+10 -7
View File
@@ -954,6 +954,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
)
: null
// Start the message fetch before publishing the selection. React flushes
// the discrete-event render in a microtask queued by `set`, so a fetch
// started after it would only leave the browser once that whole render
// finished. Started first, the request is on the wire while the render
// runs. Fire-and-forget: any transient failure is retried by the reactive
// path in ChatContainer.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({
@@ -970,13 +980,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
}
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false })
+48 -14
View File
@@ -92,11 +92,24 @@ import {
// Context
// ---------------------------------------------------------------------------
/**
* The provider's current directory as a subscribable value instead of a
* context field. A hook that is handed an explicit directory reads a constant
* snapshot from it and therefore does not re-render when the current
* directory changes; a context read would re-render every consumer every
* sidebar row on each cross-project switch.
*/
type CurrentDirectorySource = {
get: () => string
subscribe: (notify: () => void) => () => void
}
type SyncRuntime = {
childStores: ChildStoreManager
messageLoader: SessionMessageLoader
runtimeKey: string
sdk: OpencodeClient
currentDirectory: CurrentDirectorySource
}
type SyncSystem = SyncRuntime & {
@@ -165,7 +178,7 @@ function useLiveSyncSelector<T>(
isEqual: (left: T, right: T) => boolean = Object.is,
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
): T {
const { childStores } = useSyncSystem()
const { childStores } = useSyncRuntime()
const sourceRevisionRef = useRef(0)
const cacheRef = useRef<{
childStores: ChildStoreManager
@@ -2138,6 +2151,19 @@ export function SyncProvider(props: {
const routingIndex = routingIndexRef.current
const currentDirectoryRef = useRef(props.directory)
currentDirectoryRef.current = props.directory
// Written during render (above) so children rendering in the same pass read
// the new directory; subscribers are notified after commit.
const currentDirectoryListenersRef = useRef(new Set<() => void>())
const currentDirectorySource = useMemo<CurrentDirectorySource>(() => ({
get: () => currentDirectoryRef.current,
subscribe: (notify) => {
currentDirectoryListenersRef.current.add(notify)
return () => currentDirectoryListenersRef.current.delete(notify)
},
}), [])
React.useLayoutEffect(() => {
for (const notify of currentDirectoryListenersRef.current) notify()
}, [props.directory])
const lastStreamActivityAtRef = useRef(0)
const lastStatusPollAtByDirectoryRef = useRef(new Map<string, number>())
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
@@ -2149,8 +2175,8 @@ export function SyncProvider(props: {
const pipelineDisconnectedBeforeFirstConnectRef = useRef(false)
const runtime = useMemo<SyncRuntime>(
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
[childStores, messageLoader, props.sdk, runtimeKey],
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk, currentDirectory: currentDirectorySource }),
[childStores, currentDirectorySource, messageLoader, props.sdk, runtimeKey],
)
const system = useMemo<SyncSystem>(
() => ({ ...runtime, directory: props.directory }),
@@ -2700,20 +2726,25 @@ export function useDirectoryStore(
reason?: DirectoryBootstrapReason
},
): StoreApi<DirectoryStore> {
const system = useSyncSystem()
const dir = directory ?? system.directory
const store = system.childStores.ensureChild(dir, options)
const runtime = useSyncRuntime()
// With an explicit directory the snapshot is a constant, so a current-
// directory change does not re-render this consumer.
const dir = React.useSyncExternalStore(
runtime.currentDirectory.subscribe,
() => directory ?? runtime.currentDirectory.get(),
)
const store = runtime.childStores.ensureChild(dir, options)
useEffect(() => {
system.childStores.pin(dir)
return () => system.childStores.unpin(dir)
}, [dir, system.childStores])
runtime.childStores.pin(dir)
return () => runtime.childStores.unpin(dir)
}, [dir, runtime.childStores])
return store
}
export function useSessionMessageLoader(): SessionMessageLoader {
return useSyncSystem().messageLoader
return useSyncRuntime().messageLoader
}
export function useSessionMessageLoadState(sessionID: string, directory?: string): SessionMessageLoadState {
@@ -2866,7 +2897,10 @@ export function useSessionQuestions(sessionID: string, directory?: string) {
* streaming or session activity does not re-render rows.
*/
export function useSessionQuestionCount(scopes: readonly { directory: string; sessionIDs: readonly string[] }[]) {
const { childStores } = useSyncSystem()
// Runtime only: the current directory is not an input here, and reading the
// directory-bearing context would re-render every sidebar row that counts
// questions whenever the user switches projects.
const { childStores } = useSyncRuntime()
const scopedStores = React.useMemo(() => scopes.map((scope) => ({
sessionIDs: scope.sessionIDs,
store: childStores.ensureChild(scope.directory, { bootstrap: false }),
@@ -2989,7 +3023,7 @@ export function useParentSession(sessionID: string | null, directory?: string):
/** Get one session by id for a directory */
export function useSession(sessionID?: string | null, directory?: string) {
const { childStores } = useSyncSystem()
const { childStores } = useSyncRuntime()
const getSnapshot = useCallback(() => {
if (directory) {
const sessions = childStores.getChild(directory)?.getState().session
@@ -3018,7 +3052,7 @@ export function useSessionDirectory(sessionID?: string | null, directory?: strin
/** Get the SDK client */
export function useSyncSDK() {
return useSyncSystem().sdk
return useSyncRuntime().sdk
}
/** Get the current directory */
@@ -3028,7 +3062,7 @@ export function useSyncDirectory() {
/** Get the child store manager (for advanced operations) */
export function useChildStoreManager() {
return useSyncSystem().childStores
return useSyncRuntime().childStores
}
type SessionMessageRecord = { info: Message; parts: Part[] }
+25
View File
@@ -12,6 +12,7 @@ or extending these scripts. The methodology rules they enforce come from
| `bun run profile:idle` | What the app does while nobody interacts with it. |
| `bun run profile:session` | What receiving and rendering a live assistant response costs. |
| `bun run profile:animation` | What a CSS animation costs, isolated from the app. |
| `bun run profile:switch` | How long switching sessions from the sidebar takes, cold and warm. |
| `bun run profile:browser` | A manually driven capture, for interactions that cannot be scripted. |
All of them measure a real browser over CDP. Pass `--help` to any of them for
@@ -106,6 +107,30 @@ top. Note that `rotate: 360deg` is *not* equivalent to
Add a variant to `animation-fixture.html` to measure a property or technique
that is not listed.
## profile:switch
Clicks sidebar session rows with real mouse input and measures, per click, the
two moments a user feels: `ack`, when the clicked row is highlighted as active
(the first visible reaction), and `content`, when the timeline shows messages
that were not on screen before. It also reports the longest main-thread task
inside each switch and every request the switch triggered, so fan-out
regressions show up next to the latency they cause.
Every session in the plan is visited twice. The first visit is usually cold
(a network round trip for messages); the second is warm, served from the
in-memory session store. They have different budgets and are reported
separately.
```bash
bun run profile:switch -- --url http://127.0.0.1:4599 --output artifacts/switch-before
bun run profile:switch -- --url http://127.0.0.1:4599 --baseline artifacts/switch-before --budget-ack 32 --budget-content 100
```
`--sessions a,b,c` picks the rows to click; the default is the first rows in
the sidebar, so pass explicit ids to compare runs across days. The row must be
present in the sidebar; the command fails rather than measuring a click on
nothing.
## Reading The Results
Every run writes a JSON summary next to any raw capture, so results can be
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/**
* Fully automated session-switch latency capture for OpenChamber.
*
* Clicks sidebar session rows with real input events and measures, per click,
* how long the page takes to acknowledge the click and to show the target
* session's messages. Everything between those two moments is the
* "the app strains a little" feeling users report when switching sessions.
*
* Reported per switch, in milliseconds after the click:
* - `ack`: the clicked row is highlighted as active (first visible reaction);
* - `content`: the timeline shows messages that were not on screen before;
* - `longestTask`: the longest main-thread task inside the switch window;
* - the requests the switch triggered, so fan-out regressions are visible.
*
* Every session in the plan is visited twice. The first visit is usually a
* cold load (network round trip); the second is a warm switch served from the
* in-memory session store. Both are reported separately because they have
* different budgets.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join, resolve } from "node:path"
import process from "node:process"
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
import { percentile, round } from "./perf/metrics.mjs"
const HELP = `Usage: bun run profile:switch -- [options]
Measures how long switching sessions from the sidebar takes.
Options:
--url <url> OpenChamber URL (default: http://localhost:3000)
--sessions <ids> Comma-separated session ids to click, in order.
Every id is visited twice (cold, then warm).
Default: the first 6 rows in the sidebar.
--count <n> Number of sidebar rows to use when --sessions is
not given (default: 6)
--settle <seconds> Wait after load before clicking (default: 12)
--hover <ms> Rest the pointer on the row before pressing
(default: 400). Sidebar tooltips open on hover, so
a click straight after the move would measure the
tooltip opening instead of the switch.
--gap <ms> Wait after each click before the next (default: 2500)
--output <directory> Artifact directory (default: artifacts/switch-profile-<time>)
--baseline <directory> Compare against a previous run's switch-summary.json
--budget-ack <ms> Fail when the median warm ack exceeds this
--budget-content <ms> Fail when the median warm content time exceeds this
--label <text> Human label stored in the summary
--chrome <path> Chrome/Chromium executable
--headless Run without a visible browser
--help Show this help
Needs a running OpenChamber server; see scripts/perf/DOCUMENTATION.md.
`
const parseArgs = (argv) => {
const options = {
url: "http://localhost:3000",
sessions: [],
count: 6,
settle: 12,
hover: 400,
gap: 2500,
output: null,
baseline: null,
budgetAck: null,
budgetContent: null,
label: null,
chrome: null,
headless: false,
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === "--help" || value === "-h") { console.log(HELP); process.exit(0) }
else if (value === "--url") options.url = argv[++index]
else if (value === "--sessions") options.sessions = String(argv[++index]).split(",").map((id) => id.trim()).filter(Boolean)
else if (value === "--count") options.count = Number(argv[++index])
else if (value === "--settle") options.settle = Number(argv[++index])
else if (value === "--hover") options.hover = Number(argv[++index])
else if (value === "--gap") options.gap = Number(argv[++index])
else if (value === "--output") options.output = argv[++index]
else if (value === "--baseline") options.baseline = argv[++index]
else if (value === "--budget-ack") options.budgetAck = Number(argv[++index])
else if (value === "--budget-content") options.budgetContent = Number(argv[++index])
else if (value === "--label") options.label = argv[++index]
else if (value === "--chrome") options.chrome = argv[++index]
else if (value === "--headless") options.headless = true
else throw new Error(`Unknown option: ${value}`)
}
return options
}
// Installed in the page before each click. Observes the DOM until the clicked
// row is highlighted and until messages that were not on screen before appear,
// and records animation-frame timestamps so main-thread stalls are visible even
// when the trace is missing.
const buildProbeSource = (sessionId) => `(() => {
const before = new Set([...document.querySelectorAll('[data-message-id]')].map((el) => el.getAttribute('data-message-id')))
const state = { t0: null, ack: null, content: null, messageCount: null, frames: [] }
const row = () => document.querySelector('[data-session-row="${sessionId}"]')
const observer = new MutationObserver(() => {
if (state.t0 === null) return
const now = performance.now()
if (state.ack === null && row()?.getAttribute("aria-current") === "page") state.ack = now - state.t0
if (state.content === null) {
const ids = [...document.querySelectorAll('[data-message-id]')].map((el) => el.getAttribute('data-message-id'))
if (ids.length > 0 && ids.some((id) => !before.has(id))) { state.content = now - state.t0; state.messageCount = ids.length }
}
})
observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-current"] })
const tick = () => {
if (state.t0 !== null) state.frames.push(performance.now() - state.t0)
if (state.frames.length < 300) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__openchamberSwitchProbe = {
start() { state.t0 = performance.now(); performance.mark("switch:start") },
finish() {
observer.disconnect()
const gaps = []
for (let index = 1; index < state.frames.length; index += 1) gaps.push(state.frames[index] - state.frames[index - 1])
return {
ack: state.ack, content: state.content, messageCount: state.messageCount,
firstFrame: state.frames[0] ?? null,
longestFrameGap: gaps.reduce((max, gap) => Math.max(max, gap), 0),
framesRecorded: state.frames.length,
}
},
}
return true
})()`
const pressAt = async (client, x, y) => {
await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 })
await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 })
}
// Render counters worth reading per switch. They are the app's own stream
// perf counters, so the numbers mean "React renders of that component".
const RENDER_COUNTERS = [
"ui.session_sidebar.render",
"ui.sidebar_projects_list.render",
"ui.sidebar_session_node.render",
"ui.message_list.render",
"ui.chat_message.render",
"ui.markdown_renderer.settled_paint.reused",
"ui.markdown_renderer.dom_cache.hit",
]
const readRenderCounters = async (client) => {
const entries = await evaluateValue(client, `(window.__openchamberStreamPerformance?.getSnapshot().entries ?? []).map((entry) => [entry.metric, entry.count])`)
const counters = {}
for (const [metric, count] of entries ?? []) if (RENDER_COUNTERS.includes(metric)) counters[metric.replace(/^ui\./, "")] = count
return counters
}
const median = (values) => percentile(values, 0.5)
const summarizeSwitches = (switches) => {
const valid = switches.filter((entry) => entry.ack !== null && entry.content !== null)
const byVisit = (visit) => valid.filter((entry) => entry.visit === visit)
const stats = (entries, key) => ({
median: round(median(entries.map((entry) => entry[key]))),
p95: round(percentile(entries.map((entry) => entry[key]), 0.95)),
max: round(entries.reduce((max, entry) => Math.max(max, entry[key]), 0)),
})
const summary = {}
for (const visit of ["cold", "warm"]) {
const entries = byVisit(visit)
summary[visit] = entries.length === 0 ? null : {
switches: entries.length,
ack: stats(entries, "ack"),
content: stats(entries, "content"),
longestTask: stats(entries, "longestTask"),
requests: stats(entries, "requestCount"),
}
}
return summary
}
const printComparison = (current, baseline) => {
const rows = []
for (const visit of ["cold", "warm"]) {
for (const metric of ["ack", "content", "longestTask", "requests"]) {
const now = current[visit]?.[metric]?.median
const then = baseline[visit]?.[metric]?.median
if (now === undefined || then === undefined) continue
rows.push({ metric: `${visit} ${metric} (median)`, baseline: then, current: now, delta: round(now - then) })
}
}
console.table(rows)
}
const main = async () => {
const options = parseArgs(process.argv.slice(2))
const output = resolve(options.output ?? join("artifacts", `switch-profile-${new Date().toISOString().replace(/[:.]/g, "-")}`))
await mkdir(output, { recursive: true })
const profileDir = join(homedir(), ".cache", "openchamber-perf-switch-profile")
const chrome = resolveChrome(options.chrome)
const baseline = options.baseline
? JSON.parse(await readFile(join(resolve(options.baseline), "switch-summary.json"), "utf8"))
: null
const port = await reservePort()
const chromeProcess = launchChrome({ chrome, profileDir, port, headless: options.headless })
let client
try {
const target = await createPageTarget(port)
client = new CdpClient(target.webSocketDebuggerUrl)
await client.connect()
await Promise.all([
client.send("Page.enable"),
client.send("Runtime.enable"),
client.send("Profiler.enable"),
client.send("Network.enable", { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }),
])
await client.send("Network.setBypassServiceWorker", { bypass: true })
await client.send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false })
// The app's render counters are off by default; the flag is read at load.
await client.send("Page.addScriptToEvaluateOnNewDocument", {
source: `try { localStorage.setItem("openchamber_stream_perf", "1") } catch {}`,
})
let loaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.navigate", { url: options.url })
await loaded
await expandProjects(client)
loaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.reload")
await loaded
console.log(`Loaded ${options.url}; settling for ${options.settle}s.`)
await wait(options.settle * 1000)
const expanded = await expandSessionLists(client)
if (expanded > 0) await wait(3000)
const rows = await evaluateValue(client, `[...document.querySelectorAll('[data-session-row]')].map((el) => el.getAttribute('data-session-row'))`)
if (!rows || rows.length === 0) throw new Error("The sidebar rendered no session rows; the scenario never ran.")
const plan = options.sessions.length > 0 ? options.sessions : rows.slice(0, options.count)
const missing = plan.filter((id) => !rows.includes(id))
if (missing.length > 0) throw new Error(`Sessions not present in the sidebar: ${missing.join(", ")}`)
if (plan.length < 2) throw new Error("Need at least two sessions to switch between.")
console.log(`Switching between ${plan.length} sessions, two visits each.`)
const requests = new Map()
client.on("Network.requestWillBeSent", (params) => {
requests.set(params.requestId, { url: params.request.url, wallTime: params.wallTime * 1000 })
})
const traceEvents = []
client.on("Tracing.dataCollected", ({ value }) => traceEvents.push(...(value ?? [])))
await client.send("Profiler.setSamplingInterval", { interval: 250 })
await client.send("Profiler.start")
await client.send("Tracing.start", {
transferMode: "ReportEvents",
categories: ["devtools.timeline", "disabled-by-default-devtools.timeline", "blink.user_timing"].join(","),
})
const switches = []
const visits = [...plan.map((id) => ({ id, visit: "cold" })), ...plan.map((id) => ({ id, visit: "warm" }))]
for (const [index, { id, visit }] of visits.entries()) {
const box = await evaluateValue(client, `(() => {
const el = document.querySelector('[data-session-row="${id}"]')
if (!el) return null
el.scrollIntoView({ block: "center" })
const rect = el.getBoundingClientRect()
return { x: rect.x + 60, y: rect.y + rect.height / 2 }
})()`)
if (!box) throw new Error(`Row for ${id} disappeared from the sidebar.`)
await wait(800)
await client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.x, y: box.y })
await wait(options.hover)
await evaluateValue(client, buildProbeSource(id))
await evaluateValue(client, `window.__openchamberStreamPerformance?.reset()`)
const clickedAt = Date.now()
await evaluateValue(client, `window.__openchamberSwitchProbe.start()`)
await pressAt(client, box.x, box.y)
await wait(options.gap)
const probe = await evaluateValue(client, `window.__openchamberSwitchProbe.finish()`)
await evaluateValue(client, `performance.mark("switch:end")`)
const renders = await readRenderCounters(client)
const triggered = [...requests.values()]
.filter((request) => request.wallTime >= clickedAt - 5 && request.wallTime <= clickedAt + 1500)
.map((request) => ({ at: round(request.wallTime - clickedAt, 0), url: request.url.replace(options.url, "").split("?")[0] }))
const entry = { index, id, visit, ...probe, requestCount: triggered.length, requests: triggered, renders, longestTask: null }
switches.push(entry)
const renderSummary = Object.entries(renders).map(([metric, count]) => `${metric.replace(/\.render$/, "")}=${count}`).join(" ")
console.log(`#${String(index).padStart(2)} ${visit.padEnd(4)} ${id.slice(0, 16)} ack=${fmt(probe.ack)} content=${fmt(probe.content)} (${probe.messageCount ?? "-"} msgs) longestFrameGap=${fmt(probe.longestFrameGap)} requests=${triggered.length} ${renderSummary}`)
}
const tracingComplete = client.once("Tracing.tracingComplete", 120_000)
await client.send("Tracing.end")
await tracingComplete
await wait(500)
const { profile } = await client.send("Profiler.stop")
// Attribute the longest task to each switch from the user-timing marks.
const marks = traceEvents.filter((event) => event.cat?.includes("blink.user_timing") && (event.name === "switch:start" || event.name === "switch:end"))
.sort((left, right) => left.ts - right.ts)
const tasks = traceEvents.filter((event) => event.name === "RunTask" && event.ph === "X" && Number(event.dur) > 0)
if (tasks.length === 0) console.warn("Warning: the trace contains no RunTask events; longest-task metrics are unavailable, not zero.")
let switchIndex = 0
for (let markIndex = 0; markIndex + 1 < marks.length && switchIndex < switches.length; markIndex += 2) {
const start = marks[markIndex].ts
const end = marks[markIndex + 1].ts
const longest = tasks.filter((event) => event.ts >= start && event.ts <= end).reduce((max, event) => Math.max(max, event.dur / 1000), 0)
switches[switchIndex].longestTask = tasks.length === 0 ? null : round(longest)
switchIndex += 1
}
const frameLiveness = await evaluateValue(client, `new Promise((resolve) => {
let frames = 0
const startedAt = performance.now()
const tick = () => { frames += 1; if (performance.now() - startedAt < 1000) requestAnimationFrame(tick); else resolve(frames) }
requestAnimationFrame(tick)
setTimeout(() => resolve(frames), 2000)
})`)
if (Number(frameLiveness) < 20) console.warn(`Warning: the renderer produced ${frameLiveness} frames/s; it may have been throttled.`)
const summary = {
recordedAt: new Date().toISOString(),
label: options.label,
url: options.url,
sessions: plan,
...summarizeSwitches(switches),
switches,
cpuProfile: summarizeCpuProfile(profile),
}
await writeFile(join(output, "switch-summary.json"), JSON.stringify(summary, null, 2))
await writeFile(join(output, "trace.json"), JSON.stringify({ traceEvents }))
await writeFile(join(output, "cpu-profile.cpuprofile"), JSON.stringify(profile))
console.log("")
for (const visit of ["cold", "warm"]) {
const stats = summary[visit]
if (!stats) continue
console.log(`${visit}: ack median ${stats.ack.median}ms (p95 ${stats.ack.p95}) · content median ${stats.content.median}ms (p95 ${stats.content.p95}) · longest task median ${stats.longestTask.median}ms · requests median ${stats.requests.median}`)
}
if (baseline) printComparison(summary, baseline)
console.log(`Artifacts written to ${output}`)
const failures = []
if (options.budgetAck !== null && summary.warm && summary.warm.ack.median > options.budgetAck) failures.push(`warm ack median ${summary.warm.ack.median}ms exceeds ${options.budgetAck}ms`)
if (options.budgetContent !== null && summary.warm && summary.warm.content.median > options.budgetContent) failures.push(`warm content median ${summary.warm.content.median}ms exceeds ${options.budgetContent}ms`)
if (failures.length > 0) {
console.error(`Budget exceeded: ${failures.join("; ")}`)
process.exitCode = 1
}
} finally {
client?.close()
chromeProcess.kill()
}
}
const fmt = (value) => (value === null || value === undefined ? "-" : `${Math.round(value)}ms`)
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})