diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 793088a8be0..c880847de0a 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -13,6 +13,8 @@ import { StarNagToastHost } from '../components/star-nag/StarNagToastHost' import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from '../components/ZoomOverlay' import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility' +// Why eager: lazy evaluation alone delayed the first global Cmd+J results by ~300 ms. +import WorktreeJumpPalette from '../components/WorktreeJumpPalette' import { useAppStore } from '../store' import type { UpdateStatus } from '../../../shared/update-status-types' import { useLazyModalMounts } from './use-lazy-modal-mounts' @@ -20,7 +22,6 @@ import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel import type { OnboardingGate } from './use-onboarding-and-feature-tips' const QuickOpen = lazy(() => import('../components/QuickOpen')) -const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette')) const WorkspaceCleanupDialog = lazy( () => import('../components/workspace-cleanup/WorkspaceCleanupDialog') ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 2cbe63a9576..eda44ab9895 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,13 +1,5 @@ /* oxlint-disable max-lines */ -import React, { - useCallback, - useDeferredValue, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState -} from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -75,11 +67,12 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { getWorktreePaletteSearchScope, - searchWorktreeDocuments, type MatchRange, type PaletteSearchResult } from '@/lib/worktree-palette-search' import { buildWorktreePaletteDocuments } from '@/lib/worktree-palette-document' +import { useCooperativeWorktreePaletteSearch } from './cmd-j/use-cooperative-worktree-palette-search' +import { usePaintDeferredValue } from './cmd-j/use-paint-deferred-value' import { resolveWorktreeBranchLabel, resolveWorktreeDisplayName @@ -826,7 +819,7 @@ function WorktreeJumpPaletteContent({ const settingsSections = useSettingsNavigationMetadata() const [query, setQuery] = useState('') - const deferredQuery = useDeferredValue(query) + const deferredQuery = usePaintDeferredValue(query) const liveQueryRef = useRef(query) liveQueryRef.current = query // Why a ref: creation must follow an explicit ArrowDown/click, and typing re-arms @@ -1211,25 +1204,14 @@ function WorktreeJumpPaletteContent({ ] ) - const worktreeMatches = useMemo( - () => - searchWorktreeDocuments({ - worktrees: sortedWorktrees, - query: paletteSearchQuery, - documents: worktreeDocuments, - repoMap, - repoMapByHostIdentity: repoByHostIdentity, - checksReviewByWorktree - }), - [ - sortedWorktrees, - paletteSearchQuery, - worktreeDocuments, - repoByHostIdentity, - repoMap, - checksReviewByWorktree - ] - ) + const worktreeMatches = useCooperativeWorktreePaletteSearch({ + worktrees: sortedWorktrees, + query: paletteSearchQuery, + documents: worktreeDocuments, + repoMap, + repoMapByHostIdentity: repoByHostIdentity, + checksReviewByWorktree + }) const browserPageEntries = useMemo(() => { if (!paletteStatusInputsActive) { diff --git a/src/renderer/src/components/cmd-j/use-cooperative-worktree-palette-search.ts b/src/renderer/src/components/cmd-j/use-cooperative-worktree-palette-search.ts new file mode 100644 index 00000000000..43cd3f8f2fa --- /dev/null +++ b/src/renderer/src/components/cmd-j/use-cooperative-worktree-palette-search.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useState } from 'react' +import { + searchWorktreeDocuments, + searchWorktreeDocumentsCooperatively, + type PaletteSearchResult, + type WorktreePaletteSearchArgs +} from '@/lib/worktree-palette-search' +import { parseCmdJTaskSourceUrl } from '@/lib/worktree-palette-task-url-match' + +const COOPERATIVE_SEARCH_MIN_WORKTREES = 200 + +type CompletedSearch = { + request: WorktreePaletteSearchArgs + results: PaletteSearchResult[] +} + +export function useCooperativeWorktreePaletteSearch( + args: WorktreePaletteSearchArgs +): PaletteSearchResult[] { + const { worktrees, query, documents, repoMap, repoMapByHostIdentity, checksReviewByWorktree } = + args + const request = useMemo( + () => ({ + worktrees, + query, + documents, + repoMap, + repoMapByHostIdentity, + checksReviewByWorktree + }), + [worktrees, query, documents, repoMap, repoMapByHostIdentity, checksReviewByWorktree] + ) + const cooperative = + request.worktrees.length >= COOPERATIVE_SEARCH_MIN_WORKTREES && + request.query.trim().length > 0 && + parseCmdJTaskSourceUrl(request.query.trim()) === null + const immediateResults = useMemo( + () => (cooperative ? null : searchWorktreeDocuments(request)), + [cooperative, request] + ) + const [completed, setCompleted] = useState(null) + + useEffect(() => { + if (!cooperative) { + return + } + let current = true + void searchWorktreeDocumentsCooperatively(request, { + shouldContinue: () => current + }).then((results) => { + if (current && results) { + setCompleted({ request, results }) + } + }) + return () => { + current = false + } + }, [cooperative, request]) + + return immediateResults ?? (completed?.request === request ? completed.results : []) +} diff --git a/src/renderer/src/components/cmd-j/use-paint-deferred-value.ts b/src/renderer/src/components/cmd-j/use-paint-deferred-value.ts new file mode 100644 index 00000000000..613789cbc12 --- /dev/null +++ b/src/renderer/src/components/cmd-j/use-paint-deferred-value.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' + +export function usePaintDeferredValue(value: T): T { + const [deferred, setDeferred] = useState(value) + + useEffect(() => { + if (Object.is(value, deferred)) { + return + } + let current = true + const frameId = requestAnimationFrame(() => { + void yieldToEventLoop().then(() => { + if (current) { + setDeferred(value) + } + }) + }) + return () => { + current = false + cancelAnimationFrame(frameId) + } + }, [deferred, value]) + + return deferred +} diff --git a/src/renderer/src/lib/palette-match/indexed-field.ts b/src/renderer/src/lib/palette-match/indexed-field.ts index 0e87ae37895..b4a101a7d01 100644 --- a/src/renderer/src/lib/palette-match/indexed-field.ts +++ b/src/renderer/src/lib/palette-match/indexed-field.ts @@ -1,4 +1,5 @@ import { normalizePaletteText, type NormalizedText } from './normalized-text' +import { isLetterOnlyWord } from './palette-query' import { segmentPaletteText, type PaletteAtom, type PaletteWord } from './text-segments' import type { PaletteMatchQuality } from './match-quality' @@ -40,11 +41,46 @@ export type PaletteIndexedField = { text: NormalizedText atoms: readonly PaletteAtom[] words: readonly PaletteWord[] + firstAtomByText: ReadonlyMap + firstWordByText: ReadonlyMap + uniqueAtoms: readonly PaletteAtom[] + uniqueWords: readonly PaletteWord[] + wordStarts: ReadonlySet + typoWordsByLength: ReadonlyMap evidenceId: string | null identifier: PaletteIdentifierOptions | null isContainer: boolean } +function indexFirstByText(entries: readonly T[], textOf: (entry: T) => string): Map { + const firstByText = new Map() + for (const entry of entries) { + const text = textOf(entry) + if (!firstByText.has(text)) { + firstByText.set(text, entry) + } + } + return firstByText +} + +function indexTypoWordsByLength(words: readonly PaletteWord[]): Map { + const byLength = new Map() + const seen = new Set() + for (const word of words) { + if (word.text.length < 4 || !isLetterOnlyWord(word.text) || seen.has(word.text)) { + continue + } + seen.add(word.text) + const bucket = byLength.get(word.text.length) + if (bucket) { + bucket.push(word) + } else { + byLength.set(word.text.length, [word]) + } + } + return byLength +} + const IDENTIFIER_PREFIX_KINDS: ReadonlySet = new Set([ 'port', 'sha', @@ -121,12 +157,22 @@ export function indexPaletteField(source: PaletteFieldSource): PaletteIndexedFie } const text = normalizePaletteText(trimmed) const segments = segmentPaletteText(text) + const firstAtomByText = indexFirstByText(segments.atoms, (atom) => + text.normalized.slice(atom.start, atom.end) + ) + const firstWordByText = indexFirstByText(segments.words, (word) => word.text) return { id: source.id, profile: source.profile, text, atoms: segments.atoms, words: segments.words, + firstAtomByText, + firstWordByText, + uniqueAtoms: [...firstAtomByText.values()], + uniqueWords: [...firstWordByText.values()], + wordStarts: new Set(segments.words.map((word) => word.start)), + typoWordsByLength: indexTypoWordsByLength(segments.words), evidenceId: source.evidenceId ?? null, identifier: source.identifier ?? null, isContainer: Boolean(source.isContainer) diff --git a/src/renderer/src/lib/palette-match/match-field.ts b/src/renderer/src/lib/palette-match/match-field.ts index f9015ec49a5..8929d1ebee4 100644 --- a/src/renderer/src/lib/palette-match/match-field.ts +++ b/src/renderer/src/lib/palette-match/match-field.ts @@ -4,7 +4,7 @@ import { paletteProfileAllowedQualities, type PaletteIndexedField } from './indexed-field' -import { isLetterOnlyWord, type PaletteQueryToken } from './palette-query' +import type { PaletteQueryToken } from './palette-query' import type { PaletteMatchQuality } from './match-quality' import type { PaletteAtom } from './text-segments' import { isPaletteTypoCandidate, isWithinOnePaletteEdit } from './typo-distance' @@ -56,7 +56,7 @@ function passesSigilGate(field: PaletteIndexedField, token: PaletteQueryToken): } function isWordStart(field: PaletteIndexedField, index: number): boolean { - return field.words.some((word) => word.start === index) || index === 0 + return field.wordStarts.has(index) || index === 0 } function matchAtomComponentRun(atom: PaletteAtom, compact: string): [number, number] | null { @@ -92,11 +92,11 @@ function matchLiteral( return { quality: 'field-exact', ranges: toRanges(field, 0, normalized.length) } } if (qualities.has('word-exact')) { - const word = field.words.find((entry) => entry.text === text) + const word = field.firstWordByText.get(text) if (word) { return { quality: 'word-exact', ranges: toRanges(field, word.start, word.end) } } - const atom = field.atoms.find((entry) => normalized.slice(entry.start, entry.end) === text) + const atom = field.firstAtomByText.get(text) if (atom) { return { quality: 'word-exact', ranges: toRanges(field, atom.start, atom.end) } } @@ -105,8 +105,8 @@ function matchLiteral( return { quality: 'field-prefix', ranges: toRanges(field, 0, text.length) } } if (qualities.has('word-prefix')) { - const word = field.words.find((entry) => entry.text.startsWith(text)) - const atom = field.atoms.find((entry) => normalized.startsWith(text, entry.start)) + const word = field.uniqueWords.find((entry) => entry.text.startsWith(text)) + const atom = field.uniqueAtoms.find((entry) => normalized.startsWith(text, entry.start)) const start = word && atom ? Math.min(word.start, atom.start) : (word?.start ?? atom?.start) if (start !== undefined) { return { quality: 'word-prefix', ranges: toRanges(field, start, start + text.length) } @@ -157,12 +157,11 @@ function matchTypo( if (!qualities.has('typo') || !token.isLetterOnly || !isPaletteTypoCandidate(token.text)) { return null } - for (const word of field.words) { - if (!isPaletteTypoCandidate(word.text) || !isLetterOnlyWord(word.text)) { - continue - } - if (isWithinOnePaletteEdit(token.text, word.text)) { - return { quality: 'typo', ranges: toRanges(field, word.start, word.end) } + for (let length = token.text.length - 1; length <= token.text.length + 1; length += 1) { + for (const word of field.typoWordsByLength.get(length) ?? []) { + if (isWithinOnePaletteEdit(token.text, word.text)) { + return { quality: 'typo', ranges: toRanges(field, word.start, word.end) } + } } } return null diff --git a/src/renderer/src/lib/palette-match/palette-match-budget.ts b/src/renderer/src/lib/palette-match/palette-match-budget.ts index c51144fb39d..908e7e4f1da 100644 --- a/src/renderer/src/lib/palette-match/palette-match-budget.ts +++ b/src/renderer/src/lib/palette-match/palette-match-budget.ts @@ -23,3 +23,10 @@ export const PALETTE_MATCH_BUDGET = { */ documentPayloadMb: 24 } as const + +/** User-visible budgets measured by the built Electron Cmd+J flow. */ +export const PALETTE_INTERACTION_BUDGET = { + openHandlerMs: 50, + firstVisibleResultsMs: 100, + maxFrameGapMs: 50 +} as const diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index 633df3a395d..372f2568059 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getWorktreePaletteSearchScope, makeEmptyPaletteSearchResult, + searchWorktreeDocumentsCooperatively, searchWorktrees } from './worktree-palette-search' +import { buildWorktreePaletteDocuments } from './worktree-palette-document' import { WORKTREE_PALETTE_QUERY_MAX_BYTES, isWorktreePaletteQueryTooLarge @@ -545,4 +547,49 @@ describe('worktree-palette-search', () => { searchWorktrees([worktree], '300', repoMap, { workspacePortsByWorktreeId }) ).toHaveLength(1) }) + + it('cooperatively preserves complete source ordering', async () => { + const worktrees = Array.from({ length: 20 }, (_, index) => + makeWorktree({ id: `wt-${index}`, displayName: `Needle ${index}` }) + ) + const yieldBetweenSlices = vi.fn(async () => {}) + const results = await searchWorktreeDocumentsCooperatively( + { + worktrees, + query: 'needle', + documents: buildWorktreePaletteDocuments(worktrees, { repoMap }), + repoMap + }, + { timeSliceMs: 0, yieldBetweenSlices } + ) + + expect(results?.map((result) => result.worktreeId)).toEqual( + worktrees.map((worktree) => worktree.id) + ) + expect(yieldBetweenSlices).toHaveBeenCalled() + }) + + it('drops a cooperative result when its generation is stale', async () => { + const worktrees = Array.from({ length: 20 }, (_, index) => + makeWorktree({ id: `wt-${index}`, displayName: `Needle ${index}` }) + ) + let current = true + const results = await searchWorktreeDocumentsCooperatively( + { + worktrees, + query: 'needle', + documents: buildWorktreePaletteDocuments(worktrees, { repoMap }), + repoMap + }, + { + timeSliceMs: 0, + shouldContinue: () => current, + yieldBetweenSlices: async () => { + current = false + } + } + ) + + expect(results).toBeNull() + }) }) diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index 8cb637bd77a..f3fd79269fd 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -30,6 +30,7 @@ import { matchWorktreePaletteTaskUrl, parseCmdJTaskSourceUrl } from './worktree-palette-task-url-match' +import { yieldToEventLoop } from '../../../shared/event-loop-yield' export type { MatchRange } @@ -162,6 +163,45 @@ export type WorktreePaletteSearchArgs = { checksReviewByWorktree?: ReadonlyMap } +async function yieldToPalettePaint(): Promise { + if (typeof requestAnimationFrame !== 'function') { + await yieldToEventLoop() + return + } + await new Promise((resolve) => { + requestAnimationFrame(() => { + void yieldToEventLoop().then(resolve) + }) + }) +} + +function matchPreparedWorktree( + args: WorktreePaletteSearchArgs, + worktree: Worktree, + prepared: Extract, { state: 'ready' }>, + taskSourceUrl: ReturnType +): PaletteSearchResult | null { + if (taskSourceUrl) { + return matchWorktreePaletteTaskUrl({ + worktree, + intent: taskSourceUrl, + repo: resolvePaletteRepoForWorktree(worktree, args.repoMap, args.repoMapByHostIdentity), + review: args.checksReviewByWorktree?.get(worktree) + }) + } + + const document = args.documents.get(getWorktreeHostIdentity(worktree)) + if (!document) { + return null + } + const match = matchPaletteDocument({ + document, + tokens: prepared.tokens, + normalizedQuery: prepared.normalized + }) + return match ? toWorktreePaletteSearchResult(worktree.id, match, worktree.hostId) : null +} + /** Matches prepared documents; callers memoize `documents` across keystrokes. */ export function searchWorktreeDocuments(args: WorktreePaletteSearchArgs): PaletteSearchResult[] { const prepared = preparePaletteQuery(args.query) @@ -177,35 +217,50 @@ export function searchWorktreeDocuments(args: WorktreePaletteSearchArgs): Palett const taskSourceUrl = parseCmdJTaskSourceUrl(args.query.trim()) const results: PaletteSearchResult[] = [] for (const worktree of args.worktrees) { - if (taskSourceUrl) { - const match = matchWorktreePaletteTaskUrl({ - worktree, - intent: taskSourceUrl, - repo: resolvePaletteRepoForWorktree(worktree, args.repoMap, args.repoMapByHostIdentity), - review: args.checksReviewByWorktree?.get(worktree) - }) - if (match) { - results.push(match) - } - continue - } - - const document = args.documents.get(getWorktreeHostIdentity(worktree)) - if (!document) { - continue - } - const match = matchPaletteDocument({ - document, - tokens: prepared.tokens, - normalizedQuery: prepared.normalized - }) + const match = matchPreparedWorktree(args, worktree, prepared, taskSourceUrl) if (match) { - results.push(toWorktreePaletteSearchResult(worktree.id, match, worktree.hostId)) + results.push(match) } } return results } +export async function searchWorktreeDocumentsCooperatively( + args: WorktreePaletteSearchArgs, + options: { + shouldContinue?: () => boolean + timeSliceMs?: number + yieldBetweenSlices?: () => Promise + } = {} +): Promise { + const prepared = preparePaletteQuery(args.query) + if (prepared.state !== 'ready' || parseCmdJTaskSourceUrl(args.query.trim())) { + return searchWorktreeDocuments(args) + } + + const shouldContinue = options.shouldContinue ?? (() => true) + const timeSliceMs = options.timeSliceMs ?? 8 + const yieldBetweenSlices = options.yieldBetweenSlices ?? yieldToPalettePaint + const results: PaletteSearchResult[] = [] + let sliceStartedAt = performance.now() + + for (const worktree of args.worktrees) { + if (!shouldContinue()) { + return null + } + const match = matchPreparedWorktree(args, worktree, prepared, null) + if (match) { + results.push(match) + } + if (performance.now() - sliceStartedAt < timeSliceMs) { + continue + } + await yieldBetweenSlices() + sliceStartedAt = performance.now() + } + return shouldContinue() ? results : null +} + /** Convenience entry point that prepares documents inline. */ export function searchWorktrees( worktrees: readonly Worktree[], diff --git a/tests/e2e/cmd-j-cold-open-performance.spec.ts b/tests/e2e/cmd-j-cold-open-performance.spec.ts new file mode 100644 index 00000000000..f68b6c6c838 --- /dev/null +++ b/tests/e2e/cmd-j-cold-open-performance.spec.ts @@ -0,0 +1,355 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { PALETTE_INTERACTION_BUDGET } from '../../src/renderer/src/lib/palette-match/palette-match-budget' + +const WORKSPACE_COUNT = 800 +const { + openHandlerMs: MAX_OPEN_HANDLER_MS, + firstVisibleResultsMs: MAX_FIRST_VISIBLE_MS, + maxFrameGapMs: MAX_FRAME_GAP_MS +} = PALETTE_INTERACTION_BUDGET +const TARGET_QUERY = 'needle 0399' +const TARGET_LOCAL_NAME = 'Needle local 0399' +const TARGET_REMOTE_NAME = 'Needle remote 0399' + +type InteractionMetrics = { + firstVisibleMs: number + handlerMs: number | null + maxFrameGapMs: number + visibleTexts: string[] +} + +type CmdJPerformanceProbe = { + begin: (expectedTexts: string[]) => void + finish: () => InteractionMetrics | null + stop: () => void +} + +type PerformanceProbeWindow = Window & { __cmdJPerformanceProbe?: CmdJPerformanceProbe } + +async function seedAccumulatedWorkspaceCatalog(page: Page): Promise { + await page.evaluate( + ({ workspaceCount, targetLocalName, targetRemoteName }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const now = Date.now() + const sharedRepoId = 'perf-repo' + const localRepo = { + id: sharedRepoId, + path: '/perf/orca', + displayName: 'acme/orca-local', + badgeColor: '#64748b', + addedAt: now, + kind: 'git' as const, + executionHostId: 'local' as const + } + const remoteRepo = { + ...localRepo, + path: '/srv/perf/orca', + displayName: 'acme/orca-remote', + connectionId: 'perf-box', + executionHostId: 'ssh:perf-box' as const + } + const longComment = + 'Blocked on the staging relay while the execution host reconnects; review the rollback runbook and deployment evidence before retrying. '.repeat( + 6 + ) + const worktrees = Array.from({ length: workspaceCount }, (_, index) => { + const pairIndex = Math.floor(index / 2) + const suffix = String(pairIndex).padStart(4, '0') + const remote = index % 2 === 1 + const hostId = remote ? ('ssh:perf-box' as const) : ('local' as const) + const displayName = + pairIndex === 399 + ? remote + ? targetRemoteName + : targetLocalName + : `Accumulated ${remote ? 'remote' : 'local'} workspace ${suffix}` + return { + id: `${sharedRepoId}::/perf/workspace-${suffix}`, + instanceId: `perf-instance-${index}`, + repoId: sharedRepoId, + projectId: 'perf-project', + hostId, + projectHostSetupId: remote ? 'perf-ssh-setup' : 'perf-local-setup', + path: remote ? `/srv/perf/workspace-${suffix}` : `/perf/workspace-${suffix}`, + head: index.toString(16).padStart(40, 'a'), + branch: `refs/heads/perf/accumulated-${suffix}-${remote ? 'remote' : 'local'}`, + isBare: false, + isMainWorktree: pairIndex === 0, + displayName, + comment: `${longComment} Dataset row ${index}.`, + linkedIssue: 10_000 + index, + linkedPR: 20_000 + index, + linkedLinearIssue: `STA-${30_000 + index}`, + linkedWorkItem: { + provider: index % 4 === 0 ? ('gitlab' as const) : ('linear' as const), + type: index % 4 === 0 ? ('mr' as const) : ('issue' as const), + number: 30_000 + index, + title: `Rework the palette ranking pipeline for accumulated workspace ${index}`, + url: + index % 4 === 0 + ? `https://gitlab.example/acme/orca/-/merge_requests/${30_000 + index}` + : `https://linear.app/acme/issue/STA-${30_000 + index}`, + linearIdentifier: index % 4 === 0 ? undefined : `STA-${30_000 + index}` + }, + automationProvenance: { + kind: 'created-by-automation' as const, + automationId: `perf-auto-${index % 8}`, + automationNameSnapshot: 'Nightly accumulated workspace review', + automationRunId: `perf-run-${index}`, + automationRunTitleSnapshot: `Scan daily sweep ${index}`, + createdAt: now - index * 60_000, + executionTargetType: remote ? ('ssh' as const) : ('local' as const), + executionTargetId: remote ? 'perf-box' : sharedRepoId, + projectId: 'perf-project', + repoId: sharedRepoId, + hostId + }, + isArchived: false, + isUnread: index % 7 === 0, + isPinned: index % 19 === 0, + sortOrder: index, + lastActivityAt: now - (workspaceCount - index) * 1_000 + } + }) + const ports = worktrees.map((worktree, index) => ({ + id: `perf-port-${index}`, + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 3_000 + index, + pid: 40_000 + index, + processName: index % 2 === 0 ? 'node' : 'vite', + protocol: 'http' as const, + kind: 'workspace' as const, + owner: { + worktreeId: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.path, + confidence: 'cwd' as const + } + })) + + store.setState({ + repos: [localRepo, remoteRepo], + activeRepoId: sharedRepoId, + worktreesByRepo: { [sharedRepoId]: worktrees }, + activeWorktreeId: worktrees[0]!.id, + activeWorkspaceExecutionHostId: 'local', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + workspacePortScan: { + key: 'perf-800-workspaces', + result: { platform: 'darwin', scannedAt: now, ports } + }, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + activeView: 'tasks', + sidebarOpen: false, + rightSidebarOpen: false, + activeModal: undefined + }) + }, + { + workspaceCount: WORKSPACE_COUNT, + targetLocalName: TARGET_LOCAL_NAME, + targetRemoteName: TARGET_REMOTE_NAME + } + ) +} + +async function installPerformanceProbe(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + let actionStartedAt = 0 + let expectedTexts: string[] = [] + let firstVisibleAt: number | null = null + let completedMetrics: InteractionMetrics | null = null + let handlerMs: number | null = null + let maxFrameGapMs = 0 + let previousFrameAt = performance.now() + let visibleTexts: string[] = [] + let frameId = 0 + const originalOpenModal = store.getState().openModal + + store.setState({ + openModal: (...args: Parameters) => { + const startedAt = performance.now() + originalOpenModal(...args) + if (args[0] === 'worktree-palette') { + handlerMs = performance.now() - startedAt + } + } + }) + + const sampleFrame = (now: number): void => { + if (actionStartedAt > 0 && completedMetrics === null) { + maxFrameGapMs = Math.max(maxFrameGapMs, now - previousFrameAt) + const items = [...document.querySelectorAll('[cmdk-item]')].filter( + (item) => item.getClientRects().length > 0 + ) + visibleTexts = items.map((item) => item.textContent?.replace(/\s+/g, ' ').trim() ?? '') + const hasExpectedRows = + items.length > 0 && + expectedTexts.every((expected) => visibleTexts.some((text) => text.includes(expected))) + if (firstVisibleAt === null && hasExpectedRows) { + firstVisibleAt = now + completedMetrics = { + firstVisibleMs: firstVisibleAt - actionStartedAt, + handlerMs, + maxFrameGapMs, + visibleTexts + } + } + } + previousFrameAt = now + frameId = requestAnimationFrame(sampleFrame) + } + frameId = requestAnimationFrame(sampleFrame) + + ;(window as PerformanceProbeWindow).__cmdJPerformanceProbe = { + begin: (nextExpectedTexts) => { + actionStartedAt = performance.now() + previousFrameAt = actionStartedAt + expectedTexts = nextExpectedTexts + firstVisibleAt = null + completedMetrics = null + maxFrameGapMs = 0 + visibleTexts = [] + }, + finish: () => completedMetrics, + stop: () => { + cancelAnimationFrame(frameId) + store.setState({ openModal: originalOpenModal }) + } + } + }) +} + +async function beginProbe(page: Page, expectedTexts: string[]): Promise { + await page.evaluate( + (texts) => (window as PerformanceProbeWindow).__cmdJPerformanceProbe?.begin(texts), + expectedTexts + ) +} + +async function readMetrics(page: Page): Promise { + return page.evaluate( + () => (window as PerformanceProbeWindow).__cmdJPerformanceProbe?.finish() ?? null + ) +} + +async function togglePaletteFromMain(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const mainWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) + if (!mainWindow) { + throw new Error('Orca main window is not available') + } + mainWindow.webContents.send('ui:toggleWorktreePalette') + }) +} + +test.describe('Cmd-J cold accumulated-workspace performance', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('paints complete host-qualified results without dropping a frame', async ({ + electronApp, + orcaPage + }, testInfo) => { + const startupTiming = await orcaPage.evaluate(() => ({ + rendererReadyMs: performance.now(), + firstContentfulPaintMs: + performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null + })) + await seedAccumulatedWorkspaceCatalog(orcaPage) + await installPerformanceProbe(orcaPage) + await orcaPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + + await beginProbe(orcaPage, []) + await togglePaletteFromMain(electronApp) + const dialog = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await expect(dialog).toBeVisible() + await expect.poll(() => readMetrics(orcaPage), { timeout: 10_000 }).not.toBeNull() + const coldOpen = await readMetrics(orcaPage) + expect(coldOpen).not.toBeNull() + + await beginProbe(orcaPage, [ + TARGET_REMOTE_NAME, + TARGET_LOCAL_NAME, + `Create worktree "${TARGET_QUERY}"` + ]) + await dialog.getByPlaceholder(/Search chats, terminals, worktrees/).fill(TARGET_QUERY) + await expect.poll(() => readMetrics(orcaPage), { timeout: 10_000 }).not.toBeNull() + const immediateQuery = await readMetrics(orcaPage) + expect(immediateQuery).not.toBeNull() + + const matchingRows = dialog + .locator('[cmdk-item]:has([data-slot="palette-worktree-name"])') + .filter({ hasText: 'Needle' }) + await expect(matchingRows).toHaveCount(2) + const matchingTexts = (await matchingRows.allTextContents()).map((text) => + text.replace(/\s+/g, ' ').trim() + ) + expect(matchingTexts[0]).toContain(TARGET_REMOTE_NAME) + expect(matchingTexts[1]).toContain(TARGET_LOCAL_NAME) + + await togglePaletteFromMain(electronApp) + await expect(dialog).not.toBeVisible() + await orcaPage.waitForTimeout(400) + await beginProbe(orcaPage, ['Accumulated remote workspace 0398']) + await togglePaletteFromMain(electronApp) + await expect(dialog).toBeVisible() + await expect.poll(() => readMetrics(orcaPage), { timeout: 10_000 }).not.toBeNull() + const warmReopen = await readMetrics(orcaPage) + expect(warmReopen).not.toBeNull() + + const report = { + startupTiming, + coldOpen, + immediateQuery, + warmReopen, + workspaceCount: WORKSPACE_COUNT + } + await testInfo.attach('cmd-j-cold-open-metrics.json', { + body: Buffer.from(`${JSON.stringify(report, null, 2)}\n`), + contentType: 'application/json' + }) + await testInfo.attach('cmd-j-cold-open-visible.png', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) + console.log(`[cmd-j-cold-open] ${JSON.stringify(report)}`) + + expect(coldOpen!.handlerMs).not.toBeNull() + expect(coldOpen!.handlerMs!).toBeLessThanOrEqual(MAX_OPEN_HANDLER_MS) + expect(coldOpen!.firstVisibleMs).toBeLessThanOrEqual(MAX_FIRST_VISIBLE_MS) + expect(coldOpen!.maxFrameGapMs).toBeLessThanOrEqual(MAX_FRAME_GAP_MS) + expect(immediateQuery!.firstVisibleMs).toBeLessThanOrEqual(MAX_FIRST_VISIBLE_MS) + expect(immediateQuery!.maxFrameGapMs).toBeLessThanOrEqual(MAX_FRAME_GAP_MS) + expect(warmReopen!.firstVisibleMs).toBeLessThanOrEqual(MAX_FIRST_VISIBLE_MS) + expect(warmReopen!.maxFrameGapMs).toBeLessThanOrEqual(MAX_FRAME_GAP_MS) + + await orcaPage.evaluate(() => (window as PerformanceProbeWindow).__cmdJPerformanceProbe?.stop()) + }) +})