mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } } : {})
|
||||
|
||||
Reference in New Issue
Block a user