feat: cross-repo issues view in new workspace page (#942)

* feat: cross-repo issues view with multi-repo selection

- Add multi-repo selection via RepoMultiCombobox with persisted defaultRepoSelection setting (null = sticky-all)
- Stamp repoId on GitHubWorkItem at the renderer fetch boundary; merge items from all selected repos with per-repo failure tracking
- Extract task-query helpers (tokenize/strip/parseTaskQuery) to src/shared and add unit tests
- Refactor NewWorkspacePage row to <div role=button> to allow nested interactive elements without invalid-HTML hydration errors
- fix(repo-combobox): toggle all-repos selection on repeat click

* fix(tasks): route Use CTA to item's own repo in cross-repo view

handleUseWorkItem previously referenced a removed 'repoId' state; use item.repoId so launching from a merged cross-repo list targets the correct repo.

* test(e2e): add tasks page smoke test
This commit is contained in:
Jinjing
2026-04-22 10:58:18 -07:00
committed by GitHub
parent 46423cede7
commit 0bbf2d7a8f
20 changed files with 931 additions and 268 deletions
@@ -77,6 +77,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
defaultTaskViewPreset: 'all',
defaultRepoSelection: null,
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
+1
View File
@@ -71,6 +71,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
defaultTaskViewPreset: 'all',
defaultRepoSelection: null,
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
+18 -121
View File
@@ -8,6 +8,8 @@ import type {
GitHubViewer,
GitHubWorkItem
} from '../../shared/types'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import { sortWorkItemsByUpdatedAt } from '../../shared/work-items'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
@@ -89,7 +91,12 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
}
}
function mapIssueWorkItem(item: Record<string, unknown>): GitHubWorkItem {
// Why: main-process maps omit repoId because the IPC handler never receives
// a repo identifier beyond path. The renderer stamps repoId after IPC so
// single-repo and cross-repo items are uniform downstream.
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `issue:${String(item.number)}`,
type: 'issue',
@@ -116,7 +123,7 @@ function mapIssueWorkItem(item: Record<string, unknown>): GitHubWorkItem {
}
}
function mapPullRequestWorkItem(item: Record<string, unknown>): GitHubWorkItem {
function mapPullRequestWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
id: `pr:${String(item.number)}`,
type: 'pr',
@@ -158,115 +165,6 @@ function mapPullRequestWorkItem(item: Record<string, unknown>): GitHubWorkItem {
}
}
function sortWorkItemsByUpdatedAt(items: GitHubWorkItem[]): GitHubWorkItem[] {
return [...items].sort((left, right) => {
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
})
}
type ParsedTaskQuery = {
scope: 'all' | 'issue' | 'pr'
state: 'open' | 'closed' | 'all' | 'merged' | null
assignee: string | null
author: string | null
reviewRequested: string | null
reviewedBy: string | null
labels: string[]
freeText: string
}
function tokenizeSearchQuery(rawQuery: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(rawQuery)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
}
return tokens
}
function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
state: null,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
const freeTextTokens: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
if (query.scope === 'pr') {
continue
}
query.scope = 'issue'
continue
}
if (normalized === 'is:pr') {
query.scope = query.scope === 'issue' ? 'all' : 'pr'
continue
}
if (normalized === 'is:open') {
query.state = 'open'
continue
}
if (normalized === 'is:closed') {
query.state = 'closed'
continue
}
if (normalized === 'is:merged') {
query.state = 'merged'
continue
}
if (normalized === 'is:draft') {
query.scope = 'pr'
query.state = 'open'
continue
}
const [rawKey, ...rest] = token.split(':')
const value = rest.join(':').trim()
const key = rawKey.toLowerCase()
if (!value) {
freeTextTokens.push(token)
continue
}
if (key === 'assignee') {
query.assignee = value
continue
}
if (key === 'author') {
query.author = value
continue
}
if (key === 'review-requested') {
query.scope = 'pr'
query.reviewRequested = value
continue
}
if (key === 'reviewed-by') {
query.scope = 'pr'
query.reviewedBy = value
continue
}
if (key === 'label') {
query.labels.push(value)
continue
}
freeTextTokens.push(token)
}
query.freeText = freeTextTokens.join(' ').trim()
return query
}
function buildWorkItemListArgs(args: {
kind: 'issue' | 'pr'
ownerRepo: { owner: string; repo: string } | null
@@ -338,7 +236,7 @@ async function listRecentWorkItems(
repoPath: string,
ownerRepo: { owner: string; repo: string } | null,
limit: number
): Promise<GitHubWorkItem[]> {
): Promise<MainWorkItem[]> {
if (ownerRepo) {
const [issuesResult, prsResult] = await Promise.all([
ghExecFileAsync(
@@ -419,8 +317,8 @@ async function listQueriedWorkItems(
ownerRepo: { owner: string; repo: string } | null,
query: ParsedTaskQuery,
limit: number
): Promise<GitHubWorkItem[]> {
const fetchers: Promise<GitHubWorkItem[]>[] = []
): Promise<MainWorkItem[]> {
const fetchers: Promise<MainWorkItem[]>[] = []
const issueScope = query.scope !== 'pr'
const prScope = query.scope !== 'issue'
@@ -460,19 +358,21 @@ export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string
): Promise<GitHubWorkItem[]> {
): Promise<MainWorkItem[]> {
const ownerRepo = await getOwnerRepo(repoPath)
const trimmedQuery = query?.trim() ?? ''
await acquire()
try {
// Why: errors propagate to IPC so the renderer's cross-repo aggregator can
// count this repo as failed and surface the partial-failure banner. A
// catch-all here would make an auth/network failure indistinguishable from
// an empty result and silently under-report per-repo failures.
if (!trimmedQuery) {
return await listRecentWorkItems(repoPath, ownerRepo, limit)
}
const parsedQuery = parseTaskQuery(trimmedQuery)
return await listQueriedWorkItems(repoPath, ownerRepo, parsedQuery, limit)
} catch {
return []
} finally {
release()
}
@@ -484,10 +384,7 @@ export async function getRepoSlug(
return getOwnerRepo(repoPath)
}
export async function getWorkItem(
repoPath: string,
number: number
): Promise<GitHubWorkItem | null> {
export async function getWorkItem(repoPath: string, number: number): Promise<MainWorkItem | null> {
await acquire()
try {
const ownerRepo = await getOwnerRepo(repoPath)
+1 -1
View File
@@ -230,7 +230,7 @@ export async function getWorkItemDetails(
// Why: getWorkItem already handles acquire/release. We call it first (outside
// our semaphore) so the known-cheap lookup doesn't compete with the richer
// detail fetches that follow.
const item: GitHubWorkItem | null = await getWorkItem(repoPath, number)
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(repoPath, number)
if (!item) {
return null
}
+5 -2
View File
@@ -356,7 +356,10 @@ export type PreloadApi = {
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
workItem: (args: {
repoPath: string
number: number
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
workItemDetails: (args: {
repoPath: string
number: number
@@ -380,7 +383,7 @@ export type PreloadApi = {
repoPath: string
limit?: number
query?: string
}) => Promise<GitHubWorkItem[]>
}) => Promise<Omit<GitHubWorkItem, 'repoId'>[]>
prChecks: (args: {
repoPath: string
prNumber: number
+8 -2
View File
@@ -89,7 +89,13 @@ type GhApi = {
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
// Why: main-process mappers don't know the Orca Repo.id, so IPC returns
// items without `repoId`. The renderer stamps repoId based on the requesting
// repo before exposing items to UI code.
workItem: (args: {
repoPath: string
number: number
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
workItemDetails: (args: {
repoPath: string
number: number
@@ -108,7 +114,7 @@ type GhApi = {
repoPath: string
limit?: number
query?: string
}) => Promise<GitHubWorkItem[]>
}) => Promise<Omit<GitHubWorkItem, 'repoId'>[]>
prChecks: (args: {
repoPath: string
prNumber: number
+261 -122
View File
@@ -35,7 +35,9 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import RepoCombobox from '@/components/repo/RepoCombobox'
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { stripRepoQualifiers } from '../../../shared/task-query'
import GitHubItemDrawer from '@/components/GitHubItemDrawer'
import { cn } from '@/lib/utils'
import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-workspace'
@@ -145,10 +147,9 @@ export default function TaskPage(): React.JSX.Element {
const closeTaskPage = useAppStore((s) => s.closeTaskPage)
const activeModal = useAppStore((s) => s.activeModal)
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchWorkItems = useAppStore((s) => s.fetchWorkItems)
const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos)
const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems)
// Why: in workspace view (a worktree is active) App.tsx hides its
// full-width titlebar, so this page renders its own 42px titlebar strip to
@@ -165,35 +166,77 @@ export default function TaskPage(): React.JSX.Element {
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
// Why: resolve the initial repo from (1) explicit page data, (2) the app's
// currently active repo, (3) the first eligible repo. Falls back to '' so
// RepoCombobox renders its placeholder until the user picks one.
const resolvedInitialRepoId = useMemo(() => {
// Why: initial selection resolution honors (1) an explicit preselection from
// the caller, (2) the persisted defaultRepoSelection (null = sticky-all,
// array = curated subset, empty after filter = fall back to all), (3) fall
// back to "all eligible". An explicit preselection wins so "open tasks for
// this specific repo" entry points still land on a single-repo view.
const resolvedInitialSelection = useMemo<ReadonlySet<string>>(() => {
const preferred = pageData.preselectedRepoId
if (preferred && eligibleRepos.some((repo) => repo.id === preferred)) {
return preferred
return new Set([preferred])
}
if (activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId)) {
return activeRepoId
const persisted = settings?.defaultRepoSelection
if (Array.isArray(persisted)) {
const filtered = persisted.filter((id) => eligibleRepos.some((r) => r.id === id))
if (filtered.length > 0) {
return new Set(filtered)
}
// Why: empty after filtering (e.g. all persisted repos were removed)
// falls through to "all eligible" so the page never renders with an
// empty selection — see the multi-combobox invariant.
}
return eligibleRepos[0]?.id ?? ''
}, [activeRepoId, eligibleRepos, pageData.preselectedRepoId])
return new Set(eligibleRepos.map((r) => r.id))
}, [eligibleRepos, pageData.preselectedRepoId, settings?.defaultRepoSelection])
const [repoId, setRepoId] = useState<string>(resolvedInitialRepoId)
const [repoSelection, setRepoSelection] = useState<ReadonlySet<string>>(resolvedInitialSelection)
// Why: if the repo list changes such that the current repoId is no longer
// eligible (e.g. repo removed), fall back to a valid one.
// Why: prune selection when a previously-selected repo is removed, and
// preserve sticky-all (when the selection equaled every eligible repo
// pre-change, keep it equal to every eligible repo post-change so "All
// repos" stays truthful). Recreating the Set every time eligibleRepos
// changes would churn the fetch effect — only write when the identity of
// the selection actually needs to change.
const prevEligibleCountRef = useRef(eligibleRepos.length)
useEffect(() => {
if (!repoId && eligibleRepos[0]?.id) {
setRepoId(eligibleRepos[0].id)
const prevCount = prevEligibleCountRef.current
prevEligibleCountRef.current = eligibleRepos.length
const eligibleIds = new Set(eligibleRepos.map((r) => r.id))
const wasAll = repoSelection.size === prevCount && prevCount > 0
const pruned = new Set<string>()
for (const id of repoSelection) {
if (eligibleIds.has(id)) {
pruned.add(id)
}
}
if (wasAll) {
const allNow = new Set(eligibleIds)
if (allNow.size !== repoSelection.size || [...allNow].some((id) => !repoSelection.has(id))) {
setRepoSelection(allNow)
}
return
}
if (repoId && !eligibleRepos.some((repo) => repo.id === repoId)) {
setRepoId(eligibleRepos[0]?.id ?? '')
if (pruned.size === 0 && eligibleIds.size > 0) {
setRepoSelection(new Set(eligibleIds))
return
}
}, [eligibleRepos, repoId])
if (pruned.size !== repoSelection.size) {
setRepoSelection(pruned)
}
}, [eligibleRepos, repoSelection])
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepos = useMemo(
() => eligibleRepos.filter((r) => repoSelection.has(r.id)),
[eligibleRepos, repoSelection]
)
// Why: many single-repo-only affordances (new-issue dialog target, drawer
// repo path lookup, optimistic stub) need *a* repo. When exactly one is
// selected we use it; otherwise we pick the first to keep the UI
// functional, and disable the single-repo features that don't make sense
// cross-repo (new-issue button) explicitly.
const primaryRepo = selectedRepos[0] ?? null
const isSingleRepo = selectedRepos.length === 1
// 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
@@ -211,19 +254,34 @@ export default function TaskPage(): React.JSX.Element {
)
const [tasksLoading, setTasksLoading] = useState(false)
const [tasksError, setTasksError] = useState<string | null>(null)
// Why: per-repo failure count surfaced through the "N of M" banner. IPC-level
// rejections populate tasksError instead — the two are mutually exclusive so
// a successful-with-partial-failure read and a hard-reject don't double-show.
const [failedCount, setFailedCount] = useState(0)
const [taskRefreshNonce, setTaskRefreshNonce] = useState(0)
// 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.
// Why: seed from the SWR cache across every initially-selected repo so the
// first paint shows the merged-and-sorted view instantly when all repos are
// already cached. Any missing cache entry simply contributes nothing here
// and will be filled in by the effect's fetch.
const [workItems, setWorkItems] = useState<GitHubWorkItem[]>(() => {
if (!selectedRepo) {
const trimmed = initialTaskQuery.trim()
const merged: GitHubWorkItem[] = []
for (const r of selectedRepos) {
const cached = getCachedWorkItems(r.path, WORK_ITEM_LIMIT, trimmed)
if (cached) {
merged.push(...cached)
}
}
if (merged.length === 0) {
return []
}
return getCachedWorkItems(selectedRepo.path, WORK_ITEM_LIMIT, initialTaskQuery.trim()) ?? []
return [...merged]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, WORK_ITEM_LIMIT)
})
// Why: clicking a GitHub row opens this drawer for a read-only preview.
// Drawer's "Use" button routes through the same direct-launch flow as the
@@ -267,68 +325,82 @@ export default function TaskPage(): React.JSX.Element {
}, [taskSearchInput])
useEffect(() => {
if (taskSource !== 'github' || !selectedRepo) {
if (taskSource !== 'github') {
return
}
if (selectedRepos.length === 0) {
return
} // unreachable — multi-combobox forbids empty
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)
}
// Why: `repo:owner/name` qualifiers are silently dropped before fan-out
// because in cross-repo mode they would pin every per-repo fetch to a
// single repo and zero out the rest. See stripRepoQualifiers.
const q = stripRepoQualifiers(appliedTaskSearch.trim())
let cancelled = false
// 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.
// Why: paint cached rows synchronously before awaiting the fan-out so
// selection changes don't leave the previous selection's rows on screen
// for a frame. Any repo without a cache entry simply contributes nothing
// to this pre-paint; the fetch will fill it in.
const preMerged: GitHubWorkItem[] = []
let anyUncached = false
for (const r of selectedRepos) {
const cached = getCachedWorkItems(r.path, WORK_ITEM_LIMIT, q)
if (cached === null) {
anyUncached = true
} else {
preMerged.push(...cached)
}
}
// Why: always replace — if preMerged is empty (e.g. query just changed and
// no repo has a cache entry for it), we clear the previous query's rows
// rather than leaving them on screen under the spinner.
setWorkItems(
preMerged.length > 0
? [...preMerged]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, WORK_ITEM_LIMIT)
: []
)
setTasksError(null)
setFailedCount(0) // reset so a prior failure banner doesn't linger
setTasksLoading(anyUncached)
// Preserve the existing nonce-gated force behavior.
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 fetchWorkItems(repoPath, WORK_ITEM_LIMIT, trimmedQuery, {
const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path }))
void fetchWorkItemsAcrossRepos(repoArgs, WORK_ITEM_LIMIT, q, {
force: forceRefresh && taskRefreshNonce > 0
})
.then((items) => {
if (!cancelled) {
setWorkItems(items)
.then(({ items, failedCount: failed }) => {
if (cancelled) {
return
}
setWorkItems(items)
setFailedCount(failed)
setTasksLoading(false)
})
.catch((error) => {
if (!cancelled) {
setTasksError(error instanceof Error ? error.message : 'Failed to load GitHub work.')
if (!cached) {
setWorkItems([])
}
}
})
.finally(() => {
if (!cancelled) {
setTasksLoading(false)
.catch((err) => {
// Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a
// reject here means an IPC-level or programmer error — surface it.
if (cancelled) {
return
}
setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.')
setFailedCount(0) // the per-repo banner would be misleading next to tasksError
setTasksLoading(false)
})
return () => {
cancelled = true
}
// Why: getCachedWorkItems is a stable zustand selector; depending on it
// would cause unnecessary effect re-runs on unrelated store updates.
// Why: getCachedWorkItems and fetchWorkItemsAcrossRepos are stable zustand
// selectors; depending on them would re-run the effect on unrelated store
// updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedTaskSearch, selectedRepo, taskRefreshNonce, taskSource, fetchWorkItems])
}, [selectedRepos, appliedTaskSearch, taskRefreshNonce, taskSource])
const handleApplyTaskSearch = useCallback((): void => {
const trimmed = taskSearchInput.trim()
@@ -386,10 +458,10 @@ export default function TaskPage(): React.JSX.Element {
openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName: getLinkedWorkItemSuggestedName(item),
initialRepoId: repoId
initialRepoId: item.repoId
})
},
[openModal, repoId]
[openModal]
)
const handleUseWorkItem = useCallback(
@@ -402,15 +474,15 @@ export default function TaskPage(): React.JSX.Element {
// (setupRunPolicy === 'ask') or the repo/agent resolution fails.
void launchWorkItemDirect({
item,
repoId,
repoId: item.repoId,
openModalFallback: () => openComposerForItem(item)
})
},
[openComposerForItem, repoId]
[openComposerForItem]
)
const handleCreateNewIssue = useCallback(async (): Promise<void> => {
if (!selectedRepo) {
if (!primaryRepo || !isSingleRepo) {
return
}
const title = newIssueTitle.trim()
@@ -420,7 +492,7 @@ export default function TaskPage(): React.JSX.Element {
setNewIssueSubmitting(true)
try {
const result = await window.api.gh.createIssue({
repoPath: selectedRepo.path,
repoPath: primaryRepo.path,
title,
body: newIssueBody
})
@@ -447,6 +519,7 @@ export default function TaskPage(): React.JSX.Element {
// has immediate content, then refine with the full `workItem` fetch.
const stub: GitHubWorkItem = {
id: `issue:${String(result.number)}`,
repoId: primaryRepo.id,
type: 'issue',
number: result.number,
title,
@@ -457,18 +530,24 @@ export default function TaskPage(): React.JSX.Element {
author: null
}
setDrawerWorkItem(stub)
const stubRepoId = primaryRepo.id
void window.api.gh
.workItem({ repoPath: selectedRepo.path, number: result.number })
.workItem({ repoPath: primaryRepo.path, number: result.number })
.then((full) => {
if (full) {
setDrawerWorkItem(full)
// Why: `full` is `Omit<GitHubWorkItem, 'repoId'>` (IPC shape).
// Cast through unknown: spreading a discriminated union loses the
// discriminant, so `{ ...full, repoId }` doesn't typecheck as
// GitHubWorkItem. The runtime shape is correct by construction.
const withRepoId = { ...full, repoId: stubRepoId } as unknown as GitHubWorkItem
setDrawerWorkItem(withRepoId)
}
})
.catch(() => {})
} finally {
setNewIssueSubmitting(false)
}
}, [newIssueBody, newIssueSubmitting, newIssueTitle, selectedRepo])
}, [isSingleRepo, newIssueBody, newIssueSubmitting, newIssueTitle, primaryRepo])
useEffect(() => {
// Why: when a modal is open, let it own Esc dismissal.
@@ -585,7 +664,7 @@ export default function TaskPage(): React.JSX.Element {
</Tooltip>
</div>
<div className="mx-auto flex w-full max-w-[1120px] flex-1 flex-col min-h-0 px-5 pb-5 md:px-8 md:pb-7">
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pb-5 md:px-8 md:pb-7">
<div className="flex-none flex flex-col gap-5">
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
@@ -602,14 +681,14 @@ export default function TaskPage(): React.JSX.Element {
onClick={() => setTaskSource(source.id)}
aria-label={source.label}
className={cn(
'group flex h-11 w-11 items-center justify-center rounded-xl border transition',
'group flex h-8 w-8 items-center justify-center rounded-md border transition',
active
? 'border-border bg-muted/70 shadow-sm'
: 'border-border/70 bg-muted/30 hover:bg-muted/60 hover:border-border',
? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm'
: 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground',
source.disabled && 'cursor-not-allowed opacity-55'
)}
>
<source.Icon className="size-4 text-foreground" />
<source.Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
@@ -619,19 +698,34 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
<div className="w-[240px]">
<RepoCombobox
<div className="w-[200px]">
<RepoMultiCombobox
repos={eligibleRepos}
value={repoId}
onValueChange={setRepoId}
placeholder="Select a repository"
triggerClassName="h-11 w-full rounded-[10px] border border-border/50 bg-muted/50 px-3 text-sm font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
selected={repoSelection}
onChange={(next) => {
setRepoSelection(next)
// Why: persist the curated subset so the same set reopens
// next launch. Sticky-all uses onSelectAll instead.
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
onSelectAll={() => {
const allIds = new Set(eligibleRepos.map((r) => r.id))
setRepoSelection(allIds)
// Why: persist `null` so new repos added later are
// automatically included — a frozen array would exclude them.
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
</div>
</div>
{taskSource === 'github' && (
<div className="rounded-[16px] border border-border/50 bg-muted/50 p-4 shadow-sm">
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{TASK_QUERY_PRESETS.map((option) => {
@@ -652,7 +746,7 @@ export default function TaskPage(): React.JSX.Element {
handleSetDefaultTaskPreset(option.id)
}}
className={cn(
'rounded-xl border px-3 py-2 text-sm transition',
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
@@ -675,7 +769,7 @@ export default function TaskPage(): React.JSX.Element {
setNewIssueBody('')
setNewIssueOpen(true)
}}
disabled={!selectedRepo}
disabled={!primaryRepo || !isSingleRepo}
aria-label="New GitHub issue"
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
@@ -683,7 +777,9 @@ export default function TaskPage(): React.JSX.Element {
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
New GitHub issue
{isSingleRepo
? 'New GitHub issue'
: 'Select a single repo to create an issue'}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -710,15 +806,15 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[320px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={taskSearchInput}
onChange={handleTaskSearchChange}
onKeyDown={handleTaskSearchKeyDown}
placeholder="GitHub search, e.g. assignee:@me is:open"
className="h-10 border-border/50 bg-background pl-10 pr-10"
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{taskSearchInput || appliedTaskSearch ? (
<button
@@ -744,12 +840,12 @@ export default function TaskPage(): React.JSX.Element {
</div>
{taskSource === 'github' ? (
<div className="mt-4 flex min-h-0 max-h-full flex-col rounded-[16px] border border-border/50 bg-muted/50 overflow-hidden shadow-sm">
<div className="flex-none hidden grid-cols-[96px_minmax(0,1.8fr)_minmax(140px,1fr)_150px_120px_90px] gap-4 border-b border-border/50 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground lg:grid">
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_80px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>ID</span>
<span>Title / Context</span>
<span>Source Branch</span>
<span>System Status</span>
<span>Status</span>
<span>Updated</span>
<span />
</div>
@@ -764,6 +860,14 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
{!tasksError && failedCount > 0 ? (
// Why: per-repo partial-failure signal — distinct from a hard
// IPC reject (tasksError). The two are mutually exclusive.
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{failedCount} of {selectedRepos.length} repos failed to load
</div>
) : null}
{tasksLoading && filteredWorkItems.length === 0 ? (
// Why: shimmer skeleton stands in for the first ~3 rows while
// the initial fetch is in flight, so the card is never empty
@@ -773,7 +877,7 @@ export default function TaskPage(): React.JSX.Element {
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid w-full gap-4 px-4 py-4 lg:grid-cols-[96px_minmax(0,1.8fr)_minmax(140px,1fr)_150px_120px_90px]"
className="grid w-full gap-2 px-3 py-2 grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_80px]"
>
<div className="flex items-center">
<div className="h-7 w-16 animate-pulse rounded-lg bg-muted/70" />
@@ -810,37 +914,65 @@ export default function TaskPage(): React.JSX.Element {
<div className="divide-y divide-border/50">
{filteredWorkItems.map((item) => {
const itemRepo = repos.find((r) => r.id === item.repoId)
return (
<button
// Why: the row is a clickable container rather than a
// <button> because it holds nested interactive elements
// (Use button, ellipsis DropdownMenuTrigger, Radix
// TooltipTrigger). A <button> ancestor of another
// <button> is invalid HTML and triggers React hydration
// errors that break rendering of the whole page.
<div
key={item.id}
type="button"
role="button"
tabIndex={0}
onClick={() => setDrawerWorkItem(item)}
className="grid w-full gap-4 px-4 py-4 text-left transition hover:bg-muted/40 lg:grid-cols-[96px_minmax(0,1.8fr)_minmax(140px,1fr)_150px_120px_90px]"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDrawerWorkItem(item)
}
}}
className="grid w-full cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_80px]"
>
<div className="flex items-center">
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 px-2.5 py-1.5 text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/40 px-1.5 py-0.5 text-muted-foreground">
{item.type === 'pr' ? (
<GitPullRequest className="size-3.5" />
<GitPullRequest className="size-3" />
) : (
<CircleDot className="size-3.5" />
<CircleDot className="size-3" />
)}
<span className="font-mono text-[13px] font-normal">
<span className="font-mono text-[11px] font-normal">
#{item.number}
</span>
</span>
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-foreground">
{item.title}
</h3>
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-semibold text-foreground">
{item.title}
</h3>
{selectedRepos.length > 1 && itemRepo ? (
// Why: disambiguate rows when multiple repos are in
// the merged list — a single-repo view doesn't need it.
<RepoDotLabel
name={itemRepo.displayName}
color={itemRepo.badgeColor}
dotClassName="size-1.5"
className="shrink-0 text-[11px] text-muted-foreground"
/>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span>{item.author ?? 'unknown author'}</span>
<span>{selectedRepo?.displayName}</span>
{selectedRepos.length === 1 && itemRepo ? (
<span>{itemRepo.displayName}</span>
) : null}
{item.labels.slice(0, 3).map((label) => (
<span
key={label}
className="rounded-full border border-border/50 bg-background/50 backdrop-blur-md px-2 py-0.5 text-[11px] text-muted-foreground supports-[backdrop-filter]:bg-background/50"
className="rounded-full border border-border/50 bg-background/50 backdrop-blur-md px-1.5 py-0 text-[10px] text-muted-foreground supports-[backdrop-filter]:bg-background/50"
>
{label}
</span>
@@ -848,7 +980,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
<div className="min-w-0 flex items-center text-sm text-muted-foreground">
<div className="min-w-0 flex items-center text-xs text-muted-foreground">
<span className="truncate">
{item.branchName || item.baseRefName || 'workspace/default'}
</span>
@@ -857,7 +989,7 @@ export default function TaskPage(): React.JSX.Element {
<div className="flex items-center">
<span
className={cn(
'rounded-full border px-2.5 py-1 text-xs font-medium',
'rounded-full border px-2 py-0.5 text-[10px] font-medium',
getTaskStatusTone(item)
)}
>
@@ -867,7 +999,7 @@ export default function TaskPage(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center text-[11px] text-muted-foreground">
{formatRelativeTime(item.updatedAt)}
</div>
</TooltipTrigger>
@@ -888,10 +1020,10 @@ export default function TaskPage(): React.JSX.Element {
e.stopPropagation()
handleUseWorkItem(item)
}}
className="inline-flex items-center gap-1 rounded-xl border border-border/50 bg-background/50 backdrop-blur-md px-3 py-1.5 text-sm text-foreground transition hover:bg-muted/60 supports-[backdrop-filter]:bg-background/50"
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/50 backdrop-blur-md px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60 supports-[backdrop-filter]:bg-background/50"
>
Use
<ArrowRight className="size-4" />
<ArrowRight className="size-3" />
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -912,7 +1044,7 @@ export default function TaskPage(): React.JSX.Element {
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
</div>
)
})}
</div>
@@ -946,7 +1078,7 @@ export default function TaskPage(): React.JSX.Element {
<DialogHeader>
<DialogTitle>New GitHub issue</DialogTitle>
<DialogDescription>
Opens a new issue in {selectedRepo?.displayName ?? 'this repository'}.
Opens a new issue in {primaryRepo?.displayName ?? 'this repository'}.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
@@ -985,7 +1117,9 @@ export default function TaskPage(): React.JSX.Element {
</Button>
<Button
onClick={() => void handleCreateNewIssue()}
disabled={!selectedRepo || !newIssueTitle.trim() || newIssueSubmitting}
disabled={
!primaryRepo || !isSingleRepo || !newIssueTitle.trim() || newIssueSubmitting
}
>
{newIssueSubmitting ? (
<>
@@ -1002,7 +1136,12 @@ export default function TaskPage(): React.JSX.Element {
<GitHubItemDrawer
workItem={drawerWorkItem}
repoPath={selectedRepo?.path ?? null}
repoPath={
// Why: the drawer is for a single item — resolve its repoPath from the
// item's own repoId (set when fan-out merged the list) so it works in
// cross-repo mode too.
drawerWorkItem ? (repos.find((r) => r.id === drawerWorkItem.repoId)?.path ?? null) : null
}
onUse={(item) => {
setDrawerWorkItem(null)
handleUseWorkItem(item)
@@ -559,7 +559,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName = getLinkedWorkItemSuggestedName(item)
data.prefilledName = getLinkedWorkItemSuggestedName({ title: item.title })
} else {
// Fallback: we couldn't resolve the URL, just seed the name.
data.prefilledName = `${slug.owner}-${slug.repo}-${number}`
@@ -609,7 +609,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName = getLinkedWorkItemSuggestedName(item)
data.prefilledName = getLinkedWorkItemSuggestedName({ title: item.title })
} else {
data.prefilledName = trimmed
}
@@ -36,7 +36,12 @@ const SidebarNav = React.memo(function SidebarNav() {
// match TaskPage'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))
prefetchWorkItems(
firstGitRepo.id,
firstGitRepo.path,
36,
getTaskPresetQuery(defaultTaskViewPreset)
)
}
}, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repos])
@@ -0,0 +1,199 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Check, ChevronsUpDown, Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { searchRepos } from '@/lib/repo-search'
import { cn } from '@/lib/utils'
import type { Repo } from '../../../../shared/types'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
type RepoMultiComboboxProps = {
repos: Repo[]
/** Currently selected repo ids. The component enforces `selected.size >= 1`
* by disabling the last-selected checkbox. */
selected: ReadonlySet<string>
/** Called with the next full selection set whenever the user changes it.
* `null` is never emitted here — persistence of "sticky-all" (selection
* equals every eligible repo) is the caller's responsibility. */
onChange: (next: ReadonlySet<string>) => void
/** Clicking the sticky "All repos" row emits a full-set selection AND this
* signal, so the caller can persist `null` (sticky-all) rather than a
* frozen snapshot that would exclude repos added later. */
onSelectAll: () => void
triggerClassName?: string
}
function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React.JSX.Element {
if (repos.length === 0) {
return <span className="text-muted-foreground">No repos</span>
}
if (selected.size === repos.length) {
return <span className="inline-flex min-w-0 items-center gap-1.5">All repos</span>
}
const selectedRepos = repos.filter((r) => selected.has(r.id))
const [first, second, ...rest] = selectedRepos
return (
<span className="inline-flex min-w-0 items-center gap-1.5 truncate">
{first ? (
<RepoDotLabel name={first.displayName} color={first.badgeColor} dotClassName="size-1.5" />
) : null}
{second ? <span className="text-muted-foreground">, {second.displayName}</span> : null}
{rest.length > 0 ? <span className="text-muted-foreground">+{rest.length}</span> : null}
</span>
)
}
export default function RepoMultiCombobox({
repos,
selected,
onChange,
onSelectAll,
triggerClassName
}: RepoMultiComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [commandValue, setCommandValue] = useState('')
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
const allSelected = selected.size === repos.length && repos.length > 0
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuery('')
}
}, [])
const toggle = useCallback(
(repoId: string) => {
const next = new Set(selected)
if (next.has(repoId)) {
// Why: the empty selection is unreachable by design — fetch effects
// assume at least one repo is selected, so block the click instead of
// silently allowing a no-op state.
if (next.size <= 1) {
return
}
next.delete(repoId)
} else {
next.add(repoId)
}
onChange(next)
},
[onChange, selected]
)
const handleSelectAll = useCallback(() => {
if (allSelected) {
// Why: toggle — clicking "All repos" while everything is selected
// collapses to a single repo. The fetch effect requires at least one
// selection, so we keep the first eligible repo instead of emitting
// an empty set.
const first = repos[0]
if (!first) {
return
}
onChange(new Set([first.id]))
return
}
onSelectAll()
}, [allSelected, onChange, onSelectAll, repos])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)}
>
{renderTriggerLabel(repos, selected)}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
autoFocus
placeholder="Search repos..."
value={query}
onValueChange={setQuery}
className="text-xs"
/>
{/* Why: sticky "All repos" row sits above the CommandList so it
stays visible while the user scrolls a long repo list. Selecting
it emits `onSelectAll` (not a snapshot via onChange) so the
caller can persist sticky-all semantics. */}
<div className="border-b border-border">
<button
type="button"
onClick={handleSelectAll}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
allSelected && 'opacity-80'
)}
>
<Check
className={cn(
'size-3 text-muted-foreground',
allSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<span>All repos</span>
</button>
</div>
<CommandList>
<CommandEmpty>No repos match your search.</CommandEmpty>
{filteredRepos.map((repo) => {
const isSelected = selected.has(repo.id)
const isLastSelected = isSelected && selected.size <= 1
return (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => toggle(repo.id)}
disabled={isLastSelected}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
isSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1.5 text-xs">
<RepoDotLabel
name={repo.displayName}
color={repo.badgeColor}
className="max-w-full"
/>
{repo.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Globe className="size-2.5" />
SSH
</span>
)}
</span>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">{repo.path}</p>
</div>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+12 -2
View File
@@ -547,11 +547,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
let cancelled = false
setLinkItemsLoading(true)
const lookupRepoId = selectedRepo.id
void window.api.gh
.listWorkItems({ repoPath: selectedRepo.path, limit: 100 })
.then((items) => {
if (!cancelled) {
setLinkItems(items)
// Why: IPC payload omits repoId — stamp it here from the repo we
// queried so downstream consumers typed against GitHubWorkItem work.
// Cast through unknown: spreading a discriminated union loses the
// discriminant, so the union-preserving shape must be asserted.
setLinkItems(
items.map((it) => ({ ...it, repoId: lookupRepoId })) as unknown as GitHubWorkItem[]
)
}
})
.catch(() => {
@@ -583,11 +590,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// number and still get a concrete selectable result. Orca mirrors that by
// resolving direct lookups against the selected repo instead of requiring a
// text match in the recent-items list.
const lookupRepoId = selectedRepo.id
void window.api.gh
.workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber })
.then((item) => {
if (!cancelled) {
setLinkDirectItem(item)
setLinkDirectItem(
item ? ({ ...item, repoId: lookupRepoId } as unknown as GitHubWorkItem) : null
)
}
})
.catch(() => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { useAppStore } from '@/store'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { GitHubWorkItem, OrcaHooks, TaskViewPresetId } from '../../../shared/types'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
/**
* Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need
@@ -136,7 +136,7 @@ function slugifyForWorkspaceName(input: string): string {
)
}
export function getLinkedWorkItemSuggestedName(item: GitHubWorkItem): string {
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const withoutLeadingNumber = item.title
.trim()
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
+107 -11
View File
@@ -10,6 +10,7 @@ import type {
Worktree,
GitHubWorkItem
} from '../../../../shared/types'
import { sortWorkItemsByUpdatedAt } from '../../../../shared/work-items'
import { syncPRChecksStatus } from './github-checks'
export type CacheEntry<T> = {
@@ -35,9 +36,42 @@ 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[]>>()
type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
force: boolean
}
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
const prRequestGenerations = new Map<string, number>()
// Why: cap in-flight cross-repo fan-out and hover-prefetches at the renderer
// boundary — the main-side gate is behind the IPC queue, so it can't see a
// stampede until the calls are already mid-flight. 8 balances responsiveness
// against gh rate-limit pressure.
const WORK_ITEM_FETCH_CONCURRENCY = 8
let workItemFetchInFlight = 0
const workItemFetchWaiters: (() => void)[] = []
async function acquireWorkItemSlot(): Promise<void> {
if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) {
workItemFetchInFlight += 1
return
}
await new Promise<void>((resolve) => workItemFetchWaiters.push(resolve))
// Why: resolver has already claimed the slot on our behalf, so we don't
// re-increment here. Pairing convention: acquireWorkItemSlot + releaseWorkItemSlot.
}
function releaseWorkItemSlot(): void {
const next = workItemFetchWaiters.shift()
if (next) {
// Hand the slot off directly — net count unchanged — so we can't race a
// third caller into the cap between decrement and resolve.
next()
return
}
workItemFetchInFlight -= 1
}
function workItemsCacheKey(repoPath: string, limit: number, query: string): string {
return `${repoPath}::${limit}::${query}`
}
@@ -135,16 +169,31 @@ export type GitHubSlice = {
*/
getCachedWorkItems: (repoPath: string, limit: number, query: string) => GitHubWorkItem[] | null
fetchWorkItems: (
repoId: string,
repoPath: string,
limit: number,
query: string,
options?: FetchOptions
) => Promise<GitHubWorkItem[]>
/**
* Why: fan out a single work-item query across multiple repos. Partial
* failures don't reject — a repo that both fails to fetch *and* has no
* cached fallback contributes nothing and increments `failedCount`, which
* the caller surfaces as a "N of M repos failed to load" banner. A repo
* served from stale cache on rejection is NOT counted as failed — matching
* the single-repo behavior of quietly serving stale data.
*/
fetchWorkItemsAcrossRepos: (
repos: { repoId: string; path: string }[],
limit: number,
query: string,
options?: FetchOptions
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
/**
* 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
prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
@@ -159,25 +208,40 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return get().workItemsCache[key]?.data ?? null
},
fetchWorkItems: async (repoPath, limit, query, options): Promise<GitHubWorkItem[]> => {
fetchWorkItems: async (repoId, 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 existing = inflightWorkItemsRequests.get(key)
if (existing) {
// Why: a user-initiated refresh (force=true) must not silently dedupe to
// a non-forcing fetch already in flight — the result would be no fresher
// than what the user just asked to invalidate. Wait for the non-forcing
// request to settle (success or failure — we discard the result either
// way), then fall through to issue a new forced request. Non-forcing
// callers continue to dedupe onto any in-flight request as before.
if (options?.force && !existing.force) {
await existing.promise.catch(() => {})
} else {
return existing.promise
}
}
const request = (async () => {
await acquireWorkItemSlot()
try {
const items = (await window.api.gh.listWorkItems({
const raw = (await window.api.gh.listWorkItems({
repoPath,
limit,
query: query || undefined
})) as GitHubWorkItem[]
})) as Omit<GitHubWorkItem, 'repoId'>[]
// Why: stamp repoId at the renderer fetch boundary so every downstream
// consumer (cross-repo merge, row rendering, drawer) can rely on the
// field being present. Main doesn't know Orca's Repo.id.
const items: GitHubWorkItem[] = raw.map((item) => ({ ...item, repoId }))
set((s) => ({
workItemsCache: {
...s.workItemsCache,
@@ -191,15 +255,47 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
console.error('Failed to fetch GitHub work items:', err)
throw err
} finally {
releaseWorkItemSlot()
inflightWorkItemsRequests.delete(key)
}
})()
inflightWorkItemsRequests.set(key, request)
inflightWorkItemsRequests.set(key, {
promise: request,
force: Boolean(options?.force)
})
return request
},
prefetchWorkItems: (repoPath, limit = 36, query = '') => {
fetchWorkItemsAcrossRepos: async (repos, limit, query, options) => {
const state = get()
let failedCount = 0
const perRepoResults = await Promise.all(
repos.map(async (r) => {
try {
return await state.fetchWorkItems(r.repoId, r.path, limit, query, options)
} catch (err) {
// Why: fall back to any cache entry (stale or not) before declaring
// this repo failed. Matches single-repo behavior of silently serving
// stale data on error. A repo is only counted as failed when it has
// nothing at all to contribute.
const key = workItemsCacheKey(r.path, limit, query)
const cached = get().workItemsCache[key]?.data
if (cached) {
console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err)
return cached
}
console.warn(`[workItems] ${r.repoId} failed:`, err)
failedCount += 1
return [] as GitHubWorkItem[]
}
})
)
const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, limit)
return { items: merged, failedCount }
},
prefetchWorkItems: (repoId, 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.
@@ -207,7 +303,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return
}
void get()
.fetchWorkItems(repoPath, limit, query)
.fetchWorkItems(repoId, repoPath, limit, query)
.catch(() => {})
},
+1 -1
View File
@@ -189,7 +189,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
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))
state.prefetchWorkItems(repo.id, repo.path, 36, presetToQuery(preset))
}
},
closeTaskPage: () =>
+1
View File
@@ -141,6 +141,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
defaultTaskViewPreset: 'all',
defaultRepoSelection: null,
agentCmdOverrides: {},
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { parseTaskQuery, stripRepoQualifiers, tokenizeSearchQuery } from './task-query'
describe('tokenizeSearchQuery', () => {
it('splits on whitespace', () => {
expect(tokenizeSearchQuery('is:open assignee:@me foo')).toEqual([
'is:open',
'assignee:@me',
'foo'
])
})
it('unwraps standalone double-quoted tokens', () => {
expect(tokenizeSearchQuery('"needs review" foo')).toEqual(['needs review', 'foo'])
})
it('unwraps standalone single-quoted tokens', () => {
expect(tokenizeSearchQuery("'with spaces' bar")).toEqual(['with spaces', 'bar'])
})
it('returns an empty list for an empty string', () => {
expect(tokenizeSearchQuery('')).toEqual([])
})
})
describe('parseTaskQuery', () => {
it('returns defaults for an empty query', () => {
const parsed = parseTaskQuery('')
expect(parsed.scope).toBe('all')
expect(parsed.state).toBeNull()
expect(parsed.labels).toEqual([])
expect(parsed.freeText).toBe('')
})
it('parses is:issue and is:open', () => {
const parsed = parseTaskQuery('is:issue is:open')
expect(parsed.scope).toBe('issue')
expect(parsed.state).toBe('open')
})
it('widens scope to all when both is:issue and is:pr are present', () => {
const parsed = parseTaskQuery('is:issue is:pr')
expect(parsed.scope).toBe('all')
})
it('is:draft forces scope to pr and state to open', () => {
const parsed = parseTaskQuery('is:draft')
expect(parsed.scope).toBe('pr')
expect(parsed.state).toBe('open')
})
it('extracts assignee, author, label, and review qualifiers', () => {
const parsed = parseTaskQuery(
'assignee:@me author:alice review-requested:@me label:bug free text'
)
expect(parsed.assignee).toBe('@me')
expect(parsed.author).toBe('alice')
expect(parsed.reviewRequested).toBe('@me')
expect(parsed.scope).toBe('pr') // review-requested forces pr
expect(parsed.labels).toEqual(['bug'])
expect(parsed.freeText).toBe('free text')
})
it('leaves unknown qualifiers and bare words in freeText', () => {
const parsed = parseTaskQuery('custom:value hello')
expect(parsed.freeText).toBe('custom:value hello')
})
})
describe('stripRepoQualifiers', () => {
it('removes repo:owner/name tokens', () => {
expect(stripRepoQualifiers('is:open repo:foo/bar assignee:@me')).toBe('is:open assignee:@me')
})
it('is case-insensitive on the repo: key', () => {
expect(stripRepoQualifiers('REPO:Foo/Bar is:open')).toBe('is:open')
})
it('keeps other qualifiers intact', () => {
expect(stripRepoQualifiers('label:bug repo:a/b')).toBe('label:bug')
})
it('re-quotes a standalone token that contains whitespace', () => {
// Standalone quoted tokens are unwrapped by the tokenizer; the stripper
// re-wraps them in quotes so they still serialize as one token.
const stripped = stripRepoQualifiers('"needs review" repo:x/y')
expect(stripped).toBe('"needs review"')
})
it('returns empty string when only repo qualifiers are present', () => {
expect(stripRepoQualifiers('repo:foo/bar repo:baz/qux')).toBe('')
})
it('preserves a bare word containing no space', () => {
expect(stripRepoQualifiers('hello repo:a/b world')).toBe('hello world')
})
})
+131
View File
@@ -0,0 +1,131 @@
export type ParsedTaskQuery = {
scope: 'all' | 'issue' | 'pr'
state: 'open' | 'closed' | 'all' | 'merged' | null
assignee: string | null
author: string | null
reviewRequested: string | null
reviewedBy: string | null
labels: string[]
freeText: string
}
export function tokenizeSearchQuery(rawQuery: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(rawQuery)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
}
return tokens
}
export function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
state: null,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
const freeTextTokens: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
if (query.scope === 'pr') {
continue
}
query.scope = 'issue'
continue
}
if (normalized === 'is:pr') {
query.scope = query.scope === 'issue' ? 'all' : 'pr'
continue
}
if (normalized === 'is:open') {
query.state = 'open'
continue
}
if (normalized === 'is:closed') {
query.state = 'closed'
continue
}
if (normalized === 'is:merged') {
query.state = 'merged'
continue
}
if (normalized === 'is:draft') {
query.scope = 'pr'
query.state = 'open'
continue
}
const [rawKey, ...rest] = token.split(':')
const value = rest.join(':').trim()
const key = rawKey.toLowerCase()
if (!value) {
freeTextTokens.push(token)
continue
}
if (key === 'assignee') {
query.assignee = value
continue
}
if (key === 'author') {
query.author = value
continue
}
if (key === 'review-requested') {
query.scope = 'pr'
query.reviewRequested = value
continue
}
if (key === 'reviewed-by') {
query.scope = 'pr'
query.reviewedBy = value
continue
}
if (key === 'label') {
query.labels.push(value)
continue
}
freeTextTokens.push(token)
}
query.freeText = freeTextTokens.join(' ').trim()
return query
}
/**
* Strip any `repo:owner/name` qualifiers from a raw search string.
*
* Why: in cross-repo mode the renderer fans the search out to each selected
* repo via IPC. A stray `repo:` qualifier would pin every fan-out call to one
* repo and silently zero out the others, so it must be removed before dispatch.
* Tokens containing whitespace are re-quoted so quoted-label values like
* `label:"needs review"` round-trip cleanly.
*/
export function stripRepoQualifiers(rawQuery: string): string {
const kept: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
if (/^repo:[^\s]+$/i.test(token)) {
continue
}
if (/\s/.test(token)) {
const [rawKey, ...rest] = token.split(':')
if (rest.length > 0) {
kept.push(`${rawKey}:"${rest.join(':')}"`)
} else {
kept.push(`"${token}"`)
}
} else {
kept.push(token)
}
}
return kept.join(' ')
}
+15 -1
View File
@@ -399,6 +399,11 @@ export type GitHubWorkItem = {
author: string | null
branchName?: string
baseRefName?: string
/** Why: required because the cross-repo view merges items from every selected
* repo — the table row's repo pill and the "open in browser" fallback need
* to know which repo an item came from. Stamped by the renderer fetcher
* (`fetchWorkItems`) and by optimistic stubs on the new-issue path. */
repoId: string
}
export type GitHubPRFile = {
@@ -419,7 +424,9 @@ export type GitHubPRFileContents = {
}
export type GitHubWorkItemDetails = {
item: GitHubWorkItem
// Why: main-process doesn't know Orca's Repo.id, so this inner item omits
// repoId. The renderer stamps it when routing the details through the store.
item: Omit<GitHubWorkItem, 'repoId'>
body: string
comments: PRComment[]
/** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */
@@ -660,6 +667,13 @@ export type GlobalSettings = {
skipDeleteWorktreeConfirm: boolean
/** Default preset in the new-workspace GitHub task view. */
defaultTaskViewPreset: TaskViewPresetId
/** Why: persists the user's repo selection in the cross-repo tasks view.
* `null` means sticky-all — every eligible repo is selected, including
* repos added in future sessions, so the "All repos" label stays
* truthful. An explicit array freezes the curated subset; ids no longer
* eligible are silently dropped on load. An empty array after that drop
* is treated as `null`. */
defaultRepoSelection: string[] | null
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** Why: macOS terminals must choose between letting Option compose layout
+9
View File
@@ -0,0 +1,9 @@
// Why: generic over the item shape because main-process callers emit items
// without repoId (stamped by the renderer after IPC), while renderer callers
// carry the full GitHubWorkItem. Both share only the updatedAt field needed
// here.
export function sortWorkItemsByUpdatedAt<T extends { updatedAt: string }>(items: T[]): T[] {
return [...items].sort((left, right) => {
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
})
}
+54
View File
@@ -0,0 +1,54 @@
/**
* E2E tests for the Tasks page.
*
* Verifies that opening the tasks view renders correctly and that the
* repo selector, preset tabs, and close affordance are all present.
*/
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady, waitForActiveWorktree, getStoreState } from './helpers/store'
async function openTasksPage(page: Parameters<typeof getStoreState>[0]): Promise<void> {
await page.evaluate(() => {
const store = window.__store
store?.getState().openTaskPage()
})
}
test.describe('Tasks page', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('opening the tasks view renders the tasks UI', async ({ orcaPage }) => {
await openTasksPage(orcaPage)
await expect
.poll(async () => getStoreState<string>(orcaPage, 'activeView'), { timeout: 5_000 })
.toBe('tasks')
// Titlebar label, close button, and preset tabs should all render.
await expect(orcaPage.getByRole('button', { name: 'Close tasks' })).toBeVisible({
timeout: 10_000
})
await expect(orcaPage.getByRole('button', { name: 'GitHub', exact: true })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'All', exact: true })).toBeVisible()
await expect(orcaPage.getByPlaceholder(/GitHub search/i)).toBeVisible()
})
test('closing the tasks page returns to the previous view', async ({ orcaPage }) => {
const previousView = await getStoreState<string>(orcaPage, 'activeView')
await openTasksPage(orcaPage)
await expect
.poll(async () => getStoreState<string>(orcaPage, 'activeView'), { timeout: 5_000 })
.toBe('tasks')
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
await expect
.poll(async () => getStoreState<string>(orcaPage, 'activeView'), { timeout: 5_000 })
.toBe(previousView)
})
})