perf(new-workspace): cache + dedupe Create-from work-item fetches (#1263)

* 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 <help@stably.ai>

* perf(new-workspace): finish create-from launch sooner

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-04-29 17:14:37 -07:00
committed by GitHub
co-authored by Orca
parent 10a0566b13
commit 9fe66f531b
2 changed files with 156 additions and 81 deletions
@@ -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<string, { data: string[]; fetchedAt: number }>()
// 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)
}
+75 -58
View File
@@ -98,6 +98,39 @@ async function resolveSetupDecision(
}
}
async function pasteWorkItemDraftWhenAgentReady(args: {
primaryTabId: string
startupPlan: NonNullable<ReturnType<typeof buildAgentStartupPlan>>
content: string
}): Promise<void> {
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<typeof buildAgentStartupPlan> = 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<void
}
const settings = store.settings
const detectedIds = new Set(await store.ensureDetectedAgents())
const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
// Why: keep agent detection off the critical path while we resolve setup
// policy. Worktree creation only needs the startup command at activation.
const detectedAgentsPromise = store.ensureDetectedAgents()
const setupResolution = await resolveSetupDecision(repoId, repo)
if (setupResolution.kind === 'needs-modal') {
@@ -289,17 +305,6 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise<void
fallbackName
})
const startupPlan =
effectiveAgent === null
? null
: buildAgentStartupPlan({
agent: effectiveAgent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
try {
const result = await store.createWorktree(
repoId,
@@ -307,6 +312,18 @@ export async function launchFromBranch(args: LaunchFromBranchArgs): Promise<void
baseBranch,
setupResolution.decision
)
const detectedIds = new Set(await detectedAgentsPromise)
const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
const startupPlan =
effectiveAgent === null
? null
: buildAgentStartupPlan({
agent: effectiveAgent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
const activation = activateAndRevealWorktree(result.worktree.id, {
setup: result.setup,
...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {})