mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -77,6 +77,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
defaultTuiAgent: null,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
defaultRepoSelection: null,
|
||||
agentCmdOverrides: {},
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+5
-2
@@ -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
|
||||
|
||||
Vendored
+8
-2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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(() => {})
|
||||
},
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user