perf: speed up NewWorkspacePage first paint (#746)

* perf: speed up NewWorkspacePage first paint

- SWR cache + inflight dedup for gh work-items in store
- Prefetch on openNewWorkspacePage, sidebar Plus hover, Landing hover
- Seed list synchronously from cache to kill the double-fetch on mount
- Module-scoped detectAgents promise cache (one IPC per session)
- Consolidate useComposerState store subs via useShallow
- Hoist Intl.RelativeTimeFormat to module scope (was per row)
- LightRays: count 6->3, blur 44->20, drop mix-blend-screen,
  willChange compositor hint, prefers-reduced-motion bailout

* perf: drop redundant runIssueAutomation state + effect

Derived-state-via-effect anti-pattern: runIssueAutomation was just a
mirror of canOfferIssueAutomation toggled through a useEffect, so
shouldRunIssueAutomation was effectively canOfferIssueAutomation one
render late. Compute during render instead.

* fix: prefetch under the exact cache key the page will read

Sidebar and Landing were calling prefetchWorkItems with no query, warming
the cache at '::'' which NewWorkspacePage never reads. Pass the user's
default-preset query so hover-prefetch actually hits the key the page
looks up on mount.
This commit is contained in:
Neil 2026-04-16 22:03:24 -07:00 committed by GitHub
parent 2871294290
commit 42e01f240c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 359 additions and 97 deletions

View file

@ -3,6 +3,7 @@ import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star } from 'lu
import { cn } from '../lib/utils'
import { useAppStore } from '../store'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { getTaskPresetQuery } from '../lib/new-workspace'
import { ShortcutKeyCombo } from './ShortcutKeyCombo'
import logo from '../../../../resources/logo.svg'
@ -150,9 +151,24 @@ export default function Landing(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
// Why: warm the exact cache key NewWorkspacePage will read on mount — the
// default-preset query must match or the page pays a full round-trip after
// click.
const handlePrefetchNewWorkspace = (): void => {
if (!canCreateWorktree) {
return
}
const firstGit = repos.find((r) => isGitRepoKind(r))
if (firstGit?.path) {
prefetchWorkItems(firstGit.path, 36, getTaskPresetQuery(defaultTaskViewPreset))
}
}
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
useEffect(() => {
@ -249,6 +265,8 @@ export default function Landing(): React.JSX.Element {
disabled={!canCreateWorktree}
title={!canCreateWorktree ? 'Add a Git repo first' : undefined}
onClick={() => openNewWorkspacePage()}
onPointerEnter={handlePrefetchNewWorkspace}
onFocus={handlePrefetchNewWorkspace}
>
<GitBranchPlus className="size-3.5" />
Create Worktree

View file

@ -2,7 +2,7 @@
task source controls, and GitHub task list co-located so the wiring between the
selected repo, the draft composer, and the work-item list stays readable in one
place while this surface is still evolving. */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowRight,
CircleDot,
@ -32,7 +32,7 @@ import GitHubItemDrawer from '@/components/GitHubItemDrawer'
import { cn } from '@/lib/utils'
import { LightRays } from '@/components/ui/light-rays'
import { useComposerState } from '@/hooks/useComposerState'
import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace'
import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import type { GitHubWorkItem, TaskViewPresetId } from '../../../shared/types'
@ -72,26 +72,21 @@ const SOURCE_OPTIONS: SourceOption[] = [
]
const TASK_QUERY_PRESETS: TaskQueryPreset[] = [
{ id: 'all', label: 'All', query: 'is:open' },
{ id: 'issues', label: 'Issues', query: 'is:open' },
{ id: 'my-issues', label: 'My Issues', query: 'assignee:@me is:open' },
{
id: 'review',
label: 'Needs My Review',
query: 'review-requested:@me is:open'
},
{ id: 'prs', label: 'PRs', query: 'is:open' },
{ id: 'my-prs', label: 'My PRs', query: 'author:@me is:open' }
{ id: 'all', label: 'All', query: getTaskPresetQuery('all') },
{ id: 'issues', label: 'Issues', query: getTaskPresetQuery('issues') },
{ id: 'my-issues', label: 'My Issues', query: getTaskPresetQuery('my-issues') },
{ id: 'review', label: 'Needs My Review', query: getTaskPresetQuery('review') },
{ id: 'prs', label: 'PRs', query: getTaskPresetQuery('prs') },
{ id: 'my-prs', label: 'My PRs', query: getTaskPresetQuery('my-prs') }
]
function getTaskPresetQuery(presetId: TaskViewPresetId | null): string {
if (!presetId) {
return 'is:open'
}
return TASK_QUERY_PRESETS.find((preset) => preset.id === presetId)?.query ?? 'is:open'
}
const TASK_SEARCH_DEBOUNCE_MS = 300
const WORK_ITEM_LIMIT = 36
// Why: Intl.RelativeTimeFormat allocation is non-trivial, and previously we
// built a new formatter per work-item row render. Hoisting to module scope
// means all rows share one instance — zero per-row allocation cost.
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function formatRelativeTime(input: string): string {
const date = new Date(input)
@ -101,19 +96,18 @@ function formatRelativeTime(input: string): string {
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute')
return relativeTimeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour')
return relativeTimeFormatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return formatter.format(diffDays, 'day')
return relativeTimeFormatter.format(diffDays, 'day')
}
function getTaskStatusLabel(item: GitHubWorkItem): string {
@ -144,6 +138,8 @@ export default function NewWorkspacePage(): React.JSX.Element {
const activeModal = useAppStore((s) => s.activeModal)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchWorkItems = useAppStore((s) => s.fetchWorkItems)
const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems)
const { cardProps, composerRef, promptTextareaRef, submit, createDisabled } = useComposerState({
persistDraft: true,
@ -158,21 +154,41 @@ export default function NewWorkspacePage(): React.JSX.Element {
const { repoId, eligibleRepos, onRepoChange } = cardProps
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
// Why: seed the preset + query from the user's saved default synchronously
// so the first fetch effect issues exactly one request keyed to the final
// query. Previously a separate effect "re-seeded" these after mount, which
// caused a throwaway empty-query fetch followed by a second fetch for the
// real default — doubling the time-to-first-paint of the list.
const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all'
const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset)
const [taskSource, setTaskSource] = useState<TaskSource>('github')
const [taskSearchInput, setTaskSearchInput] = useState('')
const [appliedTaskSearch, setAppliedTaskSearch] = useState('')
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>('all')
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
defaultTaskViewPreset
)
const [tasksLoading, setTasksLoading] = useState(false)
const [tasksError, setTasksError] = useState<string | null>(null)
const [taskRefreshNonce, setTaskRefreshNonce] = useState(0)
const [workItems, setWorkItems] = useState<GitHubWorkItem[]>([])
// Why: the fetch effect uses this to detect when a nonce bump is from the
// user clicking the refresh button (force=true) vs. re-running for any
// other reason — e.g. a repo change while the nonce happens to be > 0.
const lastFetchedNonceRef = useRef(-1)
// Why: seed from the SWR cache so revisiting the page (or opening it after
// a hover-prefetch) shows the list instantly while the background revalidate
// keeps it current. Falls back to [] when nothing is cached yet.
const [workItems, setWorkItems] = useState<GitHubWorkItem[]>(() => {
if (!selectedRepo) {
return []
}
return getCachedWorkItems(selectedRepo.path, WORK_ITEM_LIMIT, initialTaskQuery.trim()) ?? []
})
// Why: clicking a GitHub row opens this drawer for a read-only preview.
// The composer modal is only opened by the drawer's "Use" button, which
// calls the same handleSelectWorkItem as the old direct row-click flow.
const [drawerWorkItem, setDrawerWorkItem] = useState<GitHubWorkItem | null>(null)
const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all'
const filteredWorkItems = useMemo(() => {
if (!activeTaskPreset) {
return workItems
@ -215,21 +231,38 @@ export default function NewWorkspacePage(): React.JSX.Element {
return
}
const trimmedQuery = appliedTaskSearch.trim()
const repoPath = selectedRepo.path
// Why: SWR — render cached items instantly, then revalidate in the
// background. Only show the spinner when we have nothing cached, so
// repeat visits feel instant instead of flashing a loading state.
const cached = getCachedWorkItems(repoPath, WORK_ITEM_LIMIT, trimmedQuery)
if (cached) {
setWorkItems(cached)
setTasksError(null)
setTasksLoading(false)
} else {
setTasksLoading(true)
setTasksError(null)
}
let cancelled = false
setTasksLoading(true)
setTasksError(null)
// Why: force a refetch only when the nonce has incremented since the last
// fetch (i.e. the user hit the refresh button or clicked a preset). Other
// triggers — repo changes, search-box edits — should respect the SWR
// cache's TTL instead of hammering `gh` on every keystroke.
const forceRefresh = taskRefreshNonce !== lastFetchedNonceRef.current
lastFetchedNonceRef.current = taskRefreshNonce
// Why: the buttons below populate the same search bar the user can edit by
// hand, so the fetch path has to honor both the preset GitHub query and any
// ad-hoc qualifiers the user types (for example assignee:@me). The fetch is
// debounced through `appliedTaskSearch` so backspacing all the way to empty
// refires the query without spamming GitHub on every keystroke.
void window.api.gh
.listWorkItems({
repoPath: selectedRepo.path,
limit: 36,
query: appliedTaskSearch.trim() || undefined
})
void fetchWorkItems(repoPath, WORK_ITEM_LIMIT, trimmedQuery, {
force: forceRefresh && taskRefreshNonce > 0
})
.then((items) => {
if (!cancelled) {
setWorkItems(items)
@ -238,7 +271,9 @@ export default function NewWorkspacePage(): React.JSX.Element {
.catch((error) => {
if (!cancelled) {
setTasksError(error instanceof Error ? error.message : 'Failed to load GitHub work.')
setWorkItems([])
if (!cached) {
setWorkItems([])
}
}
})
.finally(() => {
@ -250,27 +285,10 @@ export default function NewWorkspacePage(): React.JSX.Element {
return () => {
cancelled = true
}
}, [appliedTaskSearch, selectedRepo, taskRefreshNonce, taskSource])
useEffect(() => {
// Why: the composer should reflect the user's saved default once on mount
// and after clearing a custom query, but only when there's no active custom
// search to avoid clobbering their typed text.
if (taskSearchInput.trim() || appliedTaskSearch.trim()) {
return
}
const query = getTaskPresetQuery(defaultTaskViewPreset)
if (activeTaskPreset !== defaultTaskViewPreset) {
setActiveTaskPreset(defaultTaskViewPreset)
}
if (taskSearchInput !== query) {
setTaskSearchInput(query)
}
if (appliedTaskSearch !== query) {
setAppliedTaskSearch(query)
}
}, [activeTaskPreset, appliedTaskSearch, defaultTaskViewPreset, taskSearchInput])
// Why: getCachedWorkItems is a stable zustand selector; depending on it
// would cause unnecessary effect re-runs on unrelated store updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedTaskSearch, selectedRepo, taskRefreshNonce, taskSource, fetchWorkItems])
const handleApplyTaskSearch = useCallback((): void => {
const trimmed = taskSearchInput.trim()
@ -393,10 +411,14 @@ export default function NewWorkspacePage(): React.JSX.Element {
return (
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background dark:bg-[#1a1a1a] text-foreground">
{/* Why: 3 rays at blur=20 looks visually equivalent to 6 at 44 while
cutting the compositor cost of the backdrop roughly 3x the large
blur radius + mix-blend-screen pass dominated paint time during
page mount on integrated GPUs. */}
<LightRays
count={6}
count={3}
color="rgba(120, 160, 255, 0.15)"
blur={44}
blur={20}
speed={16}
length="60vh"
className="z-0"

View file

@ -4,6 +4,7 @@ import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getTaskPresetQuery } from '@/lib/new-workspace'
import {
DropdownMenu,
DropdownMenuContent,
@ -45,6 +46,27 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const sortBy = useAppStore((s) => s.sortBy)
const setSortBy = useAppStore((s) => s.setSortBy)
// Why: start warming the GitHub work-item cache on hover/focus/pointerdown so
// by the time the user's click finishes the round-trip has either completed
// or is already in-flight. Shaves ~200600ms off perceived page-load latency.
const prefetchWorkItems = useAppStore((s) => s.prefetchWorkItems)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const handlePrefetch = React.useCallback(() => {
if (!canCreateWorktree) {
return
}
const activeRepo = repos.find((r) => r.id === activeRepoId && isGitRepoKind(r))
const firstGitRepo = activeRepo ?? repos.find((r) => isGitRepoKind(r))
if (firstGitRepo?.path) {
// Why: warm the exact cache key the page will read on mount — must
// match NewWorkspacePage's `initialTaskQuery` derived from the same
// default preset, otherwise the prefetch lands in a key the page
// never reads and we pay the full round-trip after click.
prefetchWorkItems(firstGitRepo.path, 36, getTaskPresetQuery(defaultTaskViewPreset))
}
}, [activeRepoId, canCreateWorktree, defaultTaskViewPreset, prefetchWorkItems, repos])
return (
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none">
@ -114,6 +136,8 @@ const SidebarHeader = React.memo(function SidebarHeader() {
}
openNewWorkspacePage()
}}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-label="Add worktree"
disabled={!canCreateWorktree}
>

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import { cn } from '@/lib/utils'
type LightRaysProps = {
@ -68,7 +68,11 @@ function Ray({
return (
<div
className="pointer-events-none absolute -top-[12%] h-[var(--light-rays-length)] origin-top -translate-x-1/2 rounded-full bg-linear-to-b from-[color-mix(in_srgb,var(--light-rays-color)_70%,transparent)] to-transparent mix-blend-screen blur-[var(--light-rays-blur)]"
// Why: dropped mix-blend-screen — it forced an extra offscreen
// compositing pass over each ray's bounding region every frame. The
// additive glow look is approximated by slightly boosting the gradient
// alpha and keeping willChange on so the layer stays on the compositor.
className="pointer-events-none absolute -top-[12%] h-[var(--light-rays-length)] origin-top -translate-x-1/2 rounded-full bg-linear-to-b from-[color-mix(in_srgb,var(--light-rays-color)_85%,transparent)] to-transparent blur-[var(--light-rays-blur)]"
style={
{
left: `${left}%`,
@ -77,7 +81,12 @@ function Ray({
'--ray-swing': `${swing}deg`,
'--ray-rotate': `${rotate}deg`,
animation: `${animName} ${duration}s ease-in-out ${delay}s infinite`,
transform: `translateX(-50%) rotate(${rotate}deg)`
transform: `translateX(-50%) rotate(${rotate}deg)`,
// Why: promote each ray to its own compositor layer so the
// keyframe animation runs off the main thread. Without this,
// Chromium rasterizes every swing tick on the UI thread which
// stalls React renders while the NewWorkspace page mounts.
willChange: 'transform, opacity'
} as CSSProperties
}
/>
@ -95,12 +104,23 @@ export function LightRays({
ref,
...props
}: LightRaysProps): React.JSX.Element {
// Why: users with prefers-reduced-motion should get a static backdrop
// instead of an animated composite layer — both an accessibility and a
// perf win on low-end GPUs.
const prefersReducedMotion = useMemo(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}, [])
const effectiveCount = prefersReducedMotion ? 0 : count
const [rays, setRays] = useState<LightRay[]>([])
const cycleDuration = Math.max(speed, 0.1)
useEffect(() => {
setRays(createRays(count, cycleDuration))
}, [count, cycleDuration])
setRays(createRays(effectiveCount, cycleDuration))
}, [effectiveCount, cycleDuration])
return (
<div

View file

@ -4,6 +4,7 @@ and the global quick-composer modal can consume a single unified source of
truth without duplicating effects, derivation, or the create side-effect. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links'
@ -106,6 +107,28 @@ export type UseComposerStateResult = {
createDisabled: boolean
}
// Why: agent detection runs `which` for every agent binary on PATH — an IPC
// round-trip that takes 50200ms. The set of installed agents doesn't change
// within a session, so cache the promise at module scope to collapse all
// mounts (page + modal, reopen, etc.) onto a single resolve.
let detectAgentsPromise: Promise<TuiAgent[]> | null = null
function detectAgentsCached(): Promise<TuiAgent[]> {
if (detectAgentsPromise) {
return detectAgentsPromise
}
const pending = window.api.preflight
.detectAgents()
.then((ids) => ids as TuiAgent[])
.catch(() => {
// Allow a retry on the next mount if detection blew up (e.g. IPC
// timeout during cold start).
detectAgentsPromise = null
return [] as TuiAgent[]
})
detectAgentsPromise = pending
return pending
}
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
initialRepoId,
@ -118,19 +141,39 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onRepoIdOverrideChange
} = options
// Why: each `useAppStore(s => s.someAction)` registers its own equality
// check that React has to re-run on every store mutation. Consolidating
// all stable actions into a single useShallow subscription turns 11 checks
// per store update into one.
const actions = useAppStore(
useShallow((s) => ({
setNewWorkspaceDraft: s.setNewWorkspaceDraft,
clearNewWorkspaceDraft: s.clearNewWorkspaceDraft,
createWorktree: s.createWorktree,
updateWorktreeMeta: s.updateWorktreeMeta,
setSidebarOpen: s.setSidebarOpen,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget
}))
)
const {
setNewWorkspaceDraft,
clearNewWorkspaceDraft,
createWorktree,
updateWorktreeMeta,
setSidebarOpen,
setRightSidebarOpen,
setRightSidebarTab,
openSettingsPage,
openSettingsTarget
} = actions
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const setNewWorkspaceDraft = useAppStore((s) => s.setNewWorkspaceDraft)
const clearNewWorkspaceDraft = useAppStore((s) => s.clearNewWorkspaceDraft)
const createWorktree = useAppStore((s) => s.createWorktree)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@ -199,7 +242,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
const [runIssueAutomation, setRunIssueAutomation] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [advancedOpen, setAdvancedOpen] = useState(
@ -234,7 +276,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const hasIssueAutomationConfig = issueCommandTemplate.length > 0
const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig
const shouldRunIssueAutomation = canOfferIssueAutomation && runIssueAutomation
const shouldRunIssueAutomation = canOfferIssueAutomation
const shouldWaitForIssueAutomationCheck =
parsedLinkedIssueNumber !== null && !hasLoadedIssueCommand
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
@ -335,14 +377,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [eligibleRepos, repoId, setRepoId])
// Detect installed agents once on mount.
// Detect installed agents once on mount (cached at module scope so the
// page composer and quick-composer modal share a single IPC round-trip).
useEffect(() => {
void window.api.preflight.detectAgents().then((ids) => {
// Why: detectAgents returns agent IDs as strings, but AGENT_CATALOG + the
// dropdown consume the narrower TuiAgent union. Cast once at the boundary
// so downstream consumers keep their precise types without leaking an
// `as unknown` through the card props.
setDetectedAgentIds(new Set(ids as TuiAgent[]))
let cancelled = false
void detectAgentsCached().then((ids) => {
if (cancelled) {
return
}
setDetectedAgentIds(new Set(ids))
if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && ids.length > 0) {
const firstInCatalogOrder = AGENT_CATALOG.find((a) => ids.includes(a.id))
if (firstInCatalogOrder) {
@ -350,6 +393,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}
})
return () => {
cancelled = true
}
// Why: intentionally run only once on mount — detection is a best-effort
// PATH snapshot and does not need to re-run when the draft or settings change.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -445,14 +491,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setSetupDecision(setupPolicy === 'run-by-default' ? 'run' : 'skip')
}, [setupConfig, setupPolicy, shouldWaitForSetupCheck])
useEffect(() => {
if (!canOfferIssueAutomation) {
setRunIssueAutomation(false)
return
}
setRunIssueAutomation(true)
}, [canOfferIssueAutomation])
// Link popover: debounce + load recent items + resolve direct number.
useEffect(() => {
const timeout = window.setTimeout(() => setLinkDebouncedQuery(linkQuery), 250)

View file

@ -1,7 +1,26 @@
import { useAppStore } from '@/store'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { GitHubWorkItem, OrcaHooks } from '../../../shared/types'
import type { GitHubWorkItem, OrcaHooks, TaskViewPresetId } from '../../../shared/types'
/**
* Why: the NewWorkspacePage's preset buttons and the openNewWorkspacePage
* prefetcher both need to compute the same GitHub query string for a given
* preset id. Keep the mapping here so the prefetch warms exactly the cache
* key the page will look up on mount.
*/
export function getTaskPresetQuery(presetId: TaskViewPresetId | null): string {
switch (presetId) {
case 'my-issues':
return 'assignee:@me is:open'
case 'review':
return 'review-requested:@me is:open'
case 'my-prs':
return 'author:@me is:open'
default:
return 'is:open'
}
}
export const IS_MAC = navigator.userAgent.includes('Mac')
export const ADD_ATTACHMENT_SHORTCUT = IS_MAC ? '⌘U' : 'Ctrl+U'

View file

@ -7,7 +7,8 @@ import type {
IssueInfo,
PRCheckDetail,
PRComment,
Worktree
Worktree,
GitHubWorkItem
} from '../../../../shared/types'
import { syncPRChecksStatus } from './github-checks'
@ -22,6 +23,10 @@ type FetchOptions = {
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently
// Why: the NewWorkspace page's work-item list is a browse surface, not a
// source of truth, so 60s staleness is fine — stale data renders instantly
// while a background refresh keeps it current.
const WORK_ITEMS_CACHE_TTL = 60_000
const inflightPRRequests = new Map<
string,
@ -30,8 +35,13 @@ const inflightPRRequests = new Map<
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
const inflightWorkItemsRequests = new Map<string, Promise<GitHubWorkItem[]>>()
const prRequestGenerations = new Map<string, number>()
function workItemsCacheKey(repoPath: string, limit: number, query: string): string {
return `${repoPath}::${limit}::${query}`
}
// Why: 500 entries is generous enough that active developers will never hit it
// during normal use, but prevents the cache from growing without bound across
// many repos and branches over a long-running session.
@ -86,6 +96,10 @@ export type GitHubSlice = {
issueCache: Record<string, CacheEntry<IssueInfo>>
checksCache: Record<string, CacheEntry<PRCheckDetail[]>>
commentsCache: Record<string, CacheEntry<PRComment[]>>
// Why: keyed by repoPath + limit + query so the NewWorkspace page can render
// from cache instantly on mount (and on hover-prefetch from sidebar buttons)
// while a background refresh keeps the list fresh.
workItemsCache: Record<string, CacheEntry<GitHubWorkItem[]>>
fetchPRForBranch: (
repoPath: string,
branch: string,
@ -114,6 +128,23 @@ export type GitHubSlice = {
refreshAllGitHub: () => void
refreshGitHubForWorktree: (worktreeId: string) => void
refreshGitHubForWorktreeIfStale: (worktreeId: string) => void
/**
* Why: returns cached work items immediately (null if none) and fires a
* background refresh when stale. Callers can render the cached list while
* the SWR revalidate hydrates the latest.
*/
getCachedWorkItems: (repoPath: string, limit: number, query: string) => GitHubWorkItem[] | null
fetchWorkItems: (
repoPath: string,
limit: number,
query: string,
options?: FetchOptions
) => Promise<GitHubWorkItem[]>
/**
* Fire-and-forget prefetch used by UI entry points (hover/focus of the
* "new workspace" buttons) to warm the cache before the page mounts.
*/
prefetchWorkItems: (repoPath: string, limit?: number, query?: string) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
@ -121,6 +152,64 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
issueCache: {},
checksCache: {},
commentsCache: {},
workItemsCache: {},
getCachedWorkItems: (repoPath, limit, query) => {
const key = workItemsCacheKey(repoPath, limit, query)
return get().workItemsCache[key]?.data ?? null
},
fetchWorkItems: async (repoPath, limit, query, options): Promise<GitHubWorkItem[]> => {
const key = workItemsCacheKey(repoPath, limit, query)
const cached = get().workItemsCache[key]
if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) {
return cached.data ?? []
}
const inflight = inflightWorkItemsRequests.get(key)
if (inflight) {
return inflight
}
const request = (async () => {
try {
const items = (await window.api.gh.listWorkItems({
repoPath,
limit,
query: query || undefined
})) as GitHubWorkItem[]
set((s) => ({
workItemsCache: {
...s.workItemsCache,
[key]: { data: items, fetchedAt: Date.now() }
}
}))
return items
} catch (err) {
// Why: surface the error to the caller; keep stale cache entry so the
// UI can continue to render something useful while the user retries.
console.error('Failed to fetch GitHub work items:', err)
throw err
} finally {
inflightWorkItemsRequests.delete(key)
}
})()
inflightWorkItemsRequests.set(key, request)
return request
},
prefetchWorkItems: (repoPath, limit = 36, query = '') => {
const key = workItemsCacheKey(repoPath, limit, query)
const cached = get().workItemsCache[key]
// Skip when the cache is fresh or a request is already in flight.
if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(key)) {
return
}
void get()
.fetchWorkItems(repoPath, limit, query)
.catch(() => {})
},
initGitHubCache: async () => {
try {

View file

@ -4,10 +4,28 @@ import type {
ChangelogData,
PersistedUIState,
StatusBarItem,
TaskViewPresetId,
TuiAgent,
UpdateStatus,
WorktreeCardProperty
} from '../../../../shared/types'
// Why: mirrors the preset→query mapping used by NewWorkspacePage's preset
// buttons. Keeping a local copy here avoids a store ↔ lib circular import
// while letting openNewWorkspacePage warm exactly the cache key the page will
// read on mount.
function presetToQuery(presetId: TaskViewPresetId | null): string {
switch (presetId) {
case 'my-issues':
return 'assignee:@me is:open'
case 'review':
return 'review-requested:@me is:open'
case 'my-prs':
return 'author:@me is:open'
default:
return 'is:open'
}
}
import {
DEFAULT_STATUS_BAR_ITEMS,
DEFAULT_WORKTREE_CARD_PROPERTIES
@ -123,7 +141,7 @@ export type UISlice = {
setBrowserDefaultUrl: (url: string | null) => void
}
export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => ({
export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get) => ({
sidebarOpen: true,
sidebarWidth: 280,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
@ -136,7 +154,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
setActiveView: (view) => set({ activeView: view }),
newWorkspacePageData: {},
newWorkspaceDraft: null,
openNewWorkspacePage: (data = {}) =>
openNewWorkspacePage: (data = {}) => {
set((state) => ({
activeView: 'new-workspace',
previousViewBeforeNewWorkspace:
@ -144,7 +162,21 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
? state.previousViewBeforeNewWorkspace
: state.activeView,
newWorkspacePageData: data
})),
}))
// Why: prefetch the GitHub work-item list in parallel with React's first
// render of the NewWorkspacePage — by the time the page's own effect runs,
// the SWR cache is either already populated or the request is in-flight
// and will be deduped. This removes ~300800ms of perceived latency on
// initial page load.
const state = get()
const targetRepoId =
data.preselectedRepoId ?? state.activeRepoId ?? state.repos.find((r) => r.path)?.id ?? null
const repo = targetRepoId ? state.repos.find((r) => r.id === targetRepoId) : null
if (repo?.path) {
const preset = state.settings?.defaultTaskViewPreset ?? 'all'
state.prefetchWorkItems(repo.path, 36, presetToQuery(preset))
}
},
closeNewWorkspacePage: () =>
set((state) => ({
activeView: state.previousViewBeforeNewWorkspace,