mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
2871294290
commit
42e01f240c
8 changed files with 359 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ~200–600ms 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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 50–200ms. 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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ~300–800ms 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue