From 9fe66f531b786b68bd96da24d343d440dc4d4eab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:14:37 -0700 Subject: [PATCH] perf(new-workspace): cache + dedupe Create-from work-item fetches (#1263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(new-workspace): cache + dedupe Create-from work-item fetches The Create-from tab was slow because it bypassed the renderer's existing 60s work-items cache: every sub-tab switch or modal re-open fired a fresh `gh pr list --search is:pr is:open` / `gh issue list --search is:issue is:open`, which also forces the main-side slow path (`listQueriedWorkItems`, uncached) instead of the fast `listRecentWorkItems` path that wraps `gh api --cache 120s`. - Route the PR + issue effects through the store's `fetchWorkItems` so repeat opens / sub-tab toggles hit the 60s renderer cache + inflight dedupe, and show cached data instantly while revalidating. - Drop the `is:pr is:open` / `is:issue is:open` qualifiers when no query is typed so the backend takes the fast cached recent-items path. Both effects now share the same cache key on empty query, so PR ↔ Issue switching issues a single underlying gh call. - Add a small module-scoped 60s branches cache so `searchBaseRefs` isn't reshelled out every time the Branches sub-tab becomes visible. Measured on a cold orca repo: first render dropped from ~1,230 ms (`is:pr` + `is:issue` sequential) to ~45 ms for the unqualified list, with subsequent opens and sub-tab swaps serving from cache in <5 ms. Co-authored-by: Orca * perf(new-workspace): finish create-from launch sooner --------- Co-authored-by: Orca --- .../new-workspace/CreateFromTab.tsx | 104 +++++++++++--- .../src/lib/launch-work-item-direct.ts | 133 ++++++++++-------- 2 files changed, 156 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/components/new-workspace/CreateFromTab.tsx b/src/renderer/src/components/new-workspace/CreateFromTab.tsx index e2f25f6aed4..4974983b12f 100644 --- a/src/renderer/src/components/new-workspace/CreateFromTab.tsx +++ b/src/renderer/src/components/new-workspace/CreateFromTab.tsx @@ -71,6 +71,17 @@ const SUB_TABS: { const PR_LIST_LIMIT = 36 const ISSUE_LIST_LIMIT = 36 +const BRANCH_CACHE_TTL_MS = 60_000 +// Why: branches change rarely during a composer session. A module-scoped +// cache keyed on repoId + query (60s TTL) means switching sub-tabs or +// re-opening the modal returns the prior result instantly instead of +// re-shelling out to `git for-each-ref` (which can be slow on large repos). +const branchCache = new Map() +// Why: the unqualified empty-query path returns PRs + issues merged and +// sliced to `limit`. Use the sum so each tab can render up to its own cap +// after filtering by type client-side — and because both effects pass the +// same limit, they share one cached IPC call and one gh invocation. +const COMBINED_WORK_ITEM_LIMIT = PR_LIST_LIMIT + ISSUE_LIST_LIMIT const LINEAR_LIST_LIMIT = 36 const SEARCH_DEBOUNCE_MS = 200 @@ -86,7 +97,9 @@ export default function CreateFromTab({ listLinearIssues, searchLinearIssues, rememberedSubTab, - setRememberedSubTab + setRememberedSubTab, + fetchWorkItems, + getCachedWorkItems } = useAppStore( useShallow((s) => ({ activeRepoId: s.activeRepoId, @@ -95,7 +108,9 @@ export default function CreateFromTab({ listLinearIssues: s.listLinearIssues, searchLinearIssues: s.searchLinearIssues, rememberedSubTab: s.createFromSubTab, - setRememberedSubTab: s.setCreateFromSubTab + setRememberedSubTab: s.setCreateFromSubTab, + fetchWorkItems: s.fetchWorkItems, + getCachedWorkItems: s.getCachedWorkItems })) ) @@ -221,22 +236,38 @@ export default function CreateFromTab({ return // handled by direct-lookup effect below } const trimmed = debouncedQuery.trim() - const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : 'is:pr is:open' + // Why: when the user hasn't typed anything, use the unqualified listing — + // the backend routes that to `listRecentWorkItems` which hits + // `gh api --cache 120s` (fast, cached). Adding `is:pr is:open` forces the + // slow `gh pr list --search` path every time and skips the 60s renderer + // cache in the store. Same shortcut is used for the issues effect below. + const q = trimmed ? `is:pr is:open ${normalizedGhQuery.query}` : '' + // Why: empty-query path returns PRs+issues merged, so use the combined + // cap so the PR and Issue effects collapse onto the same cache key and + // dedupe into a single gh invocation via the store's inflight tracker. + const effectiveLimit = trimmed ? PR_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT + + // Why: route through the store so the 60s workItemsCache + inflight dedup + // kick in. Re-opening the modal or toggling PR↔Issue sub-tabs returns + // cached data instantly instead of re-running gh search. + const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) + if (cached) { + setPrItems(cached.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) + setPrLoading(false) + setPrError(null) + } let stale = false - setPrLoading(true) + if (!cached) { + setPrLoading(true) + } setPrError(null) - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: PR_LIST_LIMIT, query: q }) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) .then((items) => { if (stale) { return } - setPrItems( - items - .filter((i) => i.type === 'pr') - .map((i) => ({ ...i, repoId: selectedRepo.id })) as unknown as GitHubWorkItem[] - ) + setPrItems(items.filter((i) => i.type === 'pr').slice(0, PR_LIST_LIMIT)) setPrLoading(false) }) .catch((err) => { @@ -256,7 +287,9 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.directNumber + normalizedGhQuery.directNumber, + fetchWorkItems, + getCachedWorkItems ]) // --------------------------------------------------------------------- @@ -274,22 +307,30 @@ export default function CreateFromTab({ return } const trimmed = debouncedQuery.trim() - const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : 'is:issue is:open' + // Why: empty query → backend's fast cached path (see PR effect above). + // When no query is typed this is the SAME IPC call as the PR effect, so + // the store's inflight dedup collapses them into a single gh invocation. + const q = trimmed ? `is:issue is:open ${normalizedGhQuery.query}` : '' + const effectiveLimit = trimmed ? ISSUE_LIST_LIMIT : COMBINED_WORK_ITEM_LIMIT + + const cached = getCachedWorkItems(selectedRepo.path, effectiveLimit, q) + if (cached) { + setIssueItems(cached.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) + setIssueLoading(false) + setIssueError(null) + } let stale = false - setIssueLoading(true) + if (!cached) { + setIssueLoading(true) + } setIssueError(null) - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: ISSUE_LIST_LIMIT, query: q }) + void fetchWorkItems(selectedRepo.id, selectedRepo.path, effectiveLimit, q) .then((items) => { if (stale) { return } - setIssueItems( - items - .filter((i) => i.type === 'issue') - .map((i) => ({ ...i, repoId: selectedRepo.id })) as unknown as GitHubWorkItem[] - ) + setIssueItems(items.filter((i) => i.type === 'issue').slice(0, ISSUE_LIST_LIMIT)) setIssueLoading(false) }) .catch((err) => { @@ -309,7 +350,9 @@ export default function CreateFromTab({ isRemoteRepo, debouncedQuery, normalizedGhQuery.query, - normalizedGhQuery.directNumber + normalizedGhQuery.directNumber, + fetchWorkItems, + getCachedWorkItems ]) // --------------------------------------------------------------------- @@ -371,11 +414,26 @@ export default function CreateFromTab({ return } const trimmed = debouncedQuery.trim() + const cacheKey = `${selectedRepo.id}::${trimmed}` + const cached = branchCache.get(cacheKey) + const fresh = cached && Date.now() - cached.fetchedAt < BRANCH_CACHE_TTL_MS + if (cached) { + // Why: show stale cache immediately (SWR-style); the fetch below keeps + // the list current. Only skip the loader flash if the entry is fresh. + setBranches(cached.data) + if (fresh) { + setBranchesLoading(false) + return + } + } let stale = false - setBranchesLoading(true) + if (!cached) { + setBranchesLoading(true) + } void window.api.repos .searchBaseRefs({ repoId: selectedRepo.id, query: trimmed, limit: 30 }) .then((results) => { + branchCache.set(cacheKey, { data: results, fetchedAt: Date.now() }) if (!stale) { setBranches(results) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 5aa0f6baa6b..eb645d53b52 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -98,6 +98,39 @@ async function resolveSetupDecision( } } +async function pasteWorkItemDraftWhenAgentReady(args: { + primaryTabId: string + startupPlan: NonNullable> + content: string +}): Promise { + const { primaryTabId, startupPlan, content } = args + const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { + timeoutMs: 5000 + }) + if (!readyResult.ready) { + toast.message( + 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' + ) + return + } + + const finalState = useAppStore.getState() + const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] + if (!ptyId) { + return + } + + // Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can + // interpret our paste markers. `title-idle` means the TUI has fully rendered + // its input box and enabled paste mode; weaker signals (`foreground-match`, + // `child-process`) only confirm the binary is running — the TUI's input + // setup may still be in-flight, especially on slow shell environments. + const graceMs = readyResult.reason === 'title-idle' ? 150 : 600 + await new Promise((resolve) => window.setTimeout(resolve, graceMs)) + + window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) +} + /** * "Use" flow: create the workspace, activate it, launch the default agent, * and paste the work item URL into the agent's prompt as a draft (no submit). @@ -121,8 +154,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } const settings = store.settings - const detectedIds = new Set(await store.ensureDetectedAgents()) - const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + // Why: agent detection shells out and can be cold/slow. Start it now, but + // don't let it serialize setup-policy resolution or git worktree creation. + const detectedAgentsPromise = store.ensureDetectedAgents() const setupResolution = await resolveSetupDecision(repoId, repo) if (setupResolution.kind === 'needs-modal') { @@ -137,22 +171,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom linkedPR: item.type === 'pr' ? (item.number ?? null) : null }) - // Why: launch the agent with no prompt so the first frame it draws is the - // empty input box. The URL paste below populates that input buffer, which - // gives the user a reviewable draft instead of a submitted request. - const startupPlan = - effectiveAgent === null - ? null - : buildAgentStartupPlan({ - agent: effectiveAgent, - prompt: '', - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true - }) - let worktreeId: string let primaryTabId: string | null + let startupPlan: ReturnType = null try { const result = await store.createWorktree( repoId, @@ -162,6 +183,22 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ) worktreeId = result.worktree.id + const detectedIds = new Set(await detectedAgentsPromise) + const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + // Why: launch the agent with no prompt so the first frame it draws is the + // empty input box. The URL paste below populates that input buffer, which + // gives the user a reviewable draft instead of a submitted request. + startupPlan = + effectiveAgent === null + ? null + : buildAgentStartupPlan({ + agent: effectiveAgent, + prompt: '', + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + const activation = activateAndRevealWorktree(worktreeId, { setup: result.setup, ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) @@ -193,11 +230,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom meta.linkedLinearIssue = item.linearIdentifier } if (Object.keys(meta).length > 0) { - try { - await store.updateWorktreeMeta(worktreeId, meta) - } catch { + void store.updateWorktreeMeta(worktreeId, meta).catch(() => { // Meta update is non-critical for the draft flow — continue. - } + }) } store.setSidebarOpen(true) @@ -213,32 +248,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return } - const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { - timeoutMs: 5000 - }) - if (!readyResult.ready) { - toast.message( - 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' - ) - return - } - - const finalState = useAppStore.getState() - const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] - if (!ptyId) { - return - } - - // Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can - // interpret our paste markers. `title-idle` means the TUI has fully rendered - // its input box and enabled paste mode; weaker signals (`foreground-match`, - // `child-process`) only confirm the binary is running — the TUI's input - // setup may still be in-flight, especially on slow shell environments. - const graceMs = readyResult.reason === 'title-idle' ? 150 : 600 - await new Promise((resolve) => window.setTimeout(resolve, graceMs)) - const content = item.pasteContent ?? item.url - window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) + // Why: the workspace is already created and visible; waiting up to 5s for + // agent readiness here kept the Create-from modal in "Creating workspace…". + // Continue the draft paste in the background so selection latency ends when + // the worktree is ready, not when the TUI input buffer is ready. + void pasteWorkItemDraftWhenAgentReady({ primaryTabId, startupPlan, content }) } export type LaunchFromBranchArgs = { @@ -264,8 +279,9 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise