From edfc9779cf0177f45049842495a0f4354de69771 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 29 Aug 2026 17:01:15 +0300 Subject: [PATCH] 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. --- .../skills/performance-engineering/SKILL.md | 4 +- package.json | 3 +- .../ui/src/components/chat/ChatContainer.tsx | 65 ++-- packages/ui/src/components/chat/ChatInput.tsx | 22 +- .../src/components/chat/MarkdownRenderer.tsx | 30 +- .../chat/MarkdownRendererImpl.test.ts | 2 + .../components/chat/MarkdownRendererImpl.tsx | 53 ++- .../components/chat/markdownRendererLoader.ts | 28 +- .../src/components/chat/timelineRevealGate.ts | 54 +++ .../chat/work-status/WorkStatusMcpSection.tsx | 12 +- .../components/layout/SessionTabsStrip.tsx | 14 +- .../sidebar/list/SessionProjectCollection.tsx | 1 + .../sidebar/projects/useSessionGrouping.ts | 10 +- .../useSessionSidebarSections.test.tsx | 1 + .../projects/useSessionSidebarSections.ts | 31 ++ .../sidebar/sessions/SessionNodeItem.tsx | 84 ++-- .../sidebar/sessions/SessionTreeItem.tsx | 12 +- packages/ui/src/hooks/useAgentMemorySync.ts | 7 +- packages/ui/src/hooks/usePwaManifestSync.ts | 11 +- packages/ui/src/hooks/useSessionGoal.ts | 16 +- packages/ui/src/index.css | 13 +- packages/ui/src/lib/sessionKnowledgeApi.ts | Bin 4539 -> 5761 bytes packages/ui/src/main.tsx | 6 + packages/ui/src/stores/useAgentMemoryStore.ts | 23 +- packages/ui/src/stores/useConfigStore.ts | 21 +- packages/ui/src/stores/useGitStore.ts | 6 +- packages/ui/src/stores/useMcpStore.ts | 25 ++ packages/ui/src/sync/DOCUMENTATION.md | 43 +++ packages/ui/src/sync/session-ui-store.ts | 17 +- packages/ui/src/sync/sync-context.tsx | 62 ++- scripts/perf/DOCUMENTATION.md | 25 ++ scripts/profile-switch.mjs | 364 ++++++++++++++++++ 32 files changed, 947 insertions(+), 118 deletions(-) create mode 100644 packages/ui/src/components/chat/timelineRevealGate.ts create mode 100644 scripts/profile-switch.mjs diff --git a/.agents/skills/performance-engineering/SKILL.md b/.agents/skills/performance-engineering/SKILL.md index 8194aaab..e060e6de 100644 --- a/.agents/skills/performance-engineering/SKILL.md +++ b/.agents/skills/performance-engineering/SKILL.md @@ -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 diff --git a/package.json b/package.json index 6b13e36e..00d117eb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 43aa97dd..ed071edc 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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(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} >
+ + {showPromptNavigator && promptTurnIds.length >= 2 ? ( = ({ return () => setWorkStatusPanelVisible(false); }, [setWorkStatusPanelVisible, showWorkStatusPanel]); const messageListRef = React.useRef(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(null); const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); @@ -1163,15 +1197,6 @@ export const ChatContainer: React.FC = ({ 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 = ({ } 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 = ({ retryOverlay={retryOverlay} scrollToBottom={resumeToLatestInstant} endPinningReleased={userOwnsScroll} - revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 1b2e473a..ec34510f 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ // 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([ '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 = ({ : []; 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 = ({ }; 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(() => { diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 5f7f901d..599347f4 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -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> = (props) => ( - }> - - -); +export const MarkdownRenderer: React.FC> = (props) => { + const loaded = getLoadedMarkdownRendererModule(); + if (loaded) return ; + return ( + }> + + + ); +}; type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef & { fallbackContent?: React.ReactNode; }; -export const SimpleMarkdownRenderer: React.FC = ({ fallbackContent, ...props }) => ( - }> - - -); +export const SimpleMarkdownRenderer: React.FC = ({ fallbackContent, ...props }) => { + const loaded = getLoadedMarkdownRendererModule(); + if (loaded) return ; + return ( + }> + + + ); +}; export const MarkdownImageGallery: React.FC> = (props) => ( diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts index d4ce252f..a57e6710 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -193,6 +193,8 @@ const fakeReact = { return hookStates[index] as { current: T }; }, memo: (component: T): T => component, + createContext: (defaultValue: T) => ({ Provider: 'provider', defaultValue }), + useContext: (context: { defaultValue: T }): T => context.defaultValue, }; const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => { diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index c02b249b..55b356b8 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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(); 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(); let nextMarkdownDecorationId = 0; const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000; @@ -804,6 +829,16 @@ const useMorphdomMarkdown = ({ const mermaidViewerRef = React.useRef | 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; diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts index 986fbedc..033e10fe 100644 --- a/packages/ui/src/components/chat/markdownRendererLoader.ts +++ b/packages/ui/src/components/chat/markdownRendererLoader.ts @@ -1,13 +1,31 @@ -let markdownRendererModulePromise: Promise | null = null; +type MarkdownRendererModule = typeof import('./MarkdownRendererImpl'); + +let markdownRendererModulePromise: Promise | 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); }; diff --git a/packages/ui/src/components/chat/timelineRevealGate.ts b/packages/ui/src/components/chat/timelineRevealGate.ts new file mode 100644 index 00000000..8f7b1805 --- /dev/null +++ b/packages/ui/src/components/chat/timelineRevealGate.ts @@ -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(null); diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx index 3c863820..d44a846e 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx @@ -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 = ({ 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(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)), diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index bd646e2f..85413d3f 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -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 : ( - {title} +
+ {title} +
)}
{showDot ? ( diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index d6ccadb6..b1788189 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -232,6 +232,7 @@ const VisibleSessionProjects: React.FC = ({ topol availableWorktreesByProject: topology.availableWorktreesByProject, projectRepoStatus: topology.projectRepoStatus, projectRootBranches: topology.projectRootBranches, + gitBranches: topology.gitBranches, lastRepoStatus: topology.lastRepoStatus, buildGroupedSessions, hasSessionSearchQuery: view.hasSessionSearchQuery, diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts index fe6be770..cc0a17be 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts @@ -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 { diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx index 6b4038e8..0d19e9e2 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx @@ -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, diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts index e8976177..35cc465f 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts @@ -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 => 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; projectRepoStatus: Map; projectRootBranches: Map; + gitBranches: ReadonlyMap; 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(() => { diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index df251b0a..f28dd4ca 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -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) => { 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); diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx index 0eee1f45..b926ac76 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx @@ -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); diff --git a/packages/ui/src/hooks/useAgentMemorySync.ts b/packages/ui/src/hooks/useAgentMemorySync.ts index 24abdcca..c077dc25 100644 --- a/packages/ui/src/hooks/useAgentMemorySync.ts +++ b/packages/ui/src/hooks/useAgentMemorySync.ts @@ -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 diff --git a/packages/ui/src/hooks/usePwaManifestSync.ts b/packages/ui/src/hooks/usePwaManifestSync.ts index d0c01deb..b834d377 100644 --- a/packages/ui/src/hooks/usePwaManifestSync.ts +++ b/packages/ui/src/hooks/usePwaManifestSync.ts @@ -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]); }; diff --git a/packages/ui/src/hooks/useSessionGoal.ts b/packages/ui/src/hooks/useSessionGoal.ts index 729965c1..db7ebbfa 100644 --- a/packages/ui/src/hooks/useSessionGoal.ts +++ b/packages/ui/src/hooks/useSessionGoal.ts @@ -22,6 +22,9 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo }; } +const OBJECTIVE_CONTENT_CACHE_MAX = 64; +const objectiveContentByFetchKey = new Map>(); + // 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 () => { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a3c33e3b..6df624cf 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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; } } diff --git a/packages/ui/src/lib/sessionKnowledgeApi.ts b/packages/ui/src/lib/sessionKnowledgeApi.ts index 91803009ca8ebd54ead1f898fedd519c1fb3cfb9..cb3f9042ca568661d6281106c81d3562bc91b5c3 100644 GIT binary patch delta 1093 zcma)6J8u&~5QYQ^IEzPzfM%pb@mX>%MiEUMCqiIE6j?+u2&Hls@7P{^w`+FSwk&h9 z5CsM0{SSncl0QH}Maw^+qo#nlhaVv+Qk-?(nf)H~jqdBp`}HqN*IXe7%om(ltoMU} z>5$zR0M;@2qpJ;p?vc9>i*JX1L`^ZJ z17_#dKm{l*3_B=+IU5_(c2GT(#)Ib)LF3y#8cQWTKw|KHP^}muC{BP$4#Lu@G(*&2 zEE^eUE5&0KF(88$4(V=FNp8yEt7a@#NN9GLY!e>Wsz(qYf&jT7-~PyRsGAt;FKe|& zhewU4ht>MCMy(DNc-SbFO69C>Hd9NFS(EHmKoV1p^|p=GLbi)ACa+r{`@w6}<*AS8 zW^Uy{xZ@UPC<`F_I3k?+$hXK*f))K-zB_?;(3c!rLLx6rOHkPX*8wqFRhMe zS;6iu=BK z+h|>aD@^8%QVpr=32lT&uW?=gq6KaSu(k$q$2z3{JzDGSGaFM#1_PA6?Q7b(gqyr2 z8-3bLdYH4snbo7;*YCVJe`4{~Mc diff --git a/packages/ui/src/stores/useAgentMemoryStore.ts b/packages/ui/src/stores/useAgentMemoryStore.ts index 55bcbf73..f435a4f2 100644 --- a/packages/ui/src/stores/useAgentMemoryStore.ts +++ b/packages/ui/src/stores/useAgentMemoryStore.ts @@ -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; + /** + * `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; /** Re-read the store the last load used. */ refresh: () => Promise; 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((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((set, get) => ({ projectFailed: snapshot.projectFailed, loading: false, loaded: true, + loadedAt: Date.now(), disabled: false, error: null, }); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index f098ccef..b4a370ca 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -67,7 +67,26 @@ interface OpenChamberDefaults { sttLanguage?: string; } -const fetchOpenChamberDefaults = async (): Promise => { +// 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 } | null = null; + +const fetchOpenChamberDefaults = (): Promise => { + 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 => { markStartupTrace('config.defaults:start'); const started = typeof performance !== 'undefined' ? performance.now() : Date.now(); const finish = (source: string, result: OpenChamberDefaults) => { diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 04e9c7c9..07b19619 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -639,8 +639,12 @@ export const useGitStore = create()( 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; diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index 1f9d2867..915c83c1 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -53,6 +53,8 @@ type RefreshOptions = { silent?: boolean; }; +const ensureFreshInFlight = new Map>(); + type TestConnectionResult = { status?: McpStatus; error?: string; @@ -64,11 +66,19 @@ interface McpStore { diagnosticsByDirectory: Record; loadingKeys: Record; lastErrorKeys: Record; + /** When each directory's status was last fetched successfully. */ + refreshedAtKeys: Record; getStatusForDirectory: (directory?: string | null) => McpStatusMap; getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap; getErrorForDirectory: (directory?: string | null) => string | null; refresh: (options?: RefreshOptions) => Promise; + /** + * 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; connect: (name: string, directory?: string | null) => Promise; disconnect: (name: string, directory?: string | null) => Promise; startAuth: (name: string, directory?: string | null) => Promise; @@ -89,6 +99,7 @@ export const useMcpStore = create()( diagnosticsByDirectory: {}, loadingKeys: {}, lastErrorKeys: {}, + refreshedAtKeys: {}, getStatusForDirectory: (directory) => { const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory); @@ -131,6 +142,7 @@ export const useMcpStore = create()( }, 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()( } }, + 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); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 18e59a04..b9fa70ae 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 8b432d35..74fbac64 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -954,6 +954,16 @@ export const useSessionUIStore = create()((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()((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 }) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 2b6215d2..dea2fa4a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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( 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(() => ({ + 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()) const lastFullResyncAtByDirectoryRef = useRef(new Map()) @@ -2149,8 +2175,8 @@ export function SyncProvider(props: { const pipelineDisconnectedBeforeFirstConnectRef = useRef(false) const runtime = useMemo( - () => ({ 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( () => ({ ...runtime, directory: props.directory }), @@ -2700,20 +2726,25 @@ export function useDirectoryStore( reason?: DirectoryBootstrapReason }, ): StoreApi { - 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[] } diff --git a/scripts/perf/DOCUMENTATION.md b/scripts/perf/DOCUMENTATION.md index 1303eb4c..3bc310c4 100644 --- a/scripts/perf/DOCUMENTATION.md +++ b/scripts/perf/DOCUMENTATION.md @@ -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 diff --git a/scripts/profile-switch.mjs b/scripts/profile-switch.mjs new file mode 100644 index 00000000..3133af29 --- /dev/null +++ b/scripts/profile-switch.mjs @@ -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 OpenChamber URL (default: http://localhost:3000) + --sessions 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 Number of sidebar rows to use when --sessions is + not given (default: 6) + --settle Wait after load before clicking (default: 12) + --hover 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 Wait after each click before the next (default: 2500) + --output Artifact directory (default: artifacts/switch-profile-