From 0bbf2d7a8fa27c71668abe52a131769866e779bc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:58:18 -0700 Subject: [PATCH] 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
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 --- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/github/client.ts | 139 +------ src/main/github/work-item-details.ts | 2 +- src/preload/api-types.d.ts | 7 +- src/preload/index.d.ts | 10 +- src/renderer/src/components/TaskPage.tsx | 383 ++++++++++++------ .../src/components/WorktreeJumpPalette.tsx | 4 +- .../src/components/sidebar/SidebarNav.tsx | 7 +- .../src/components/ui/repo-multi-combobox.tsx | 199 +++++++++ src/renderer/src/hooks/useComposerState.ts | 14 +- src/renderer/src/lib/new-workspace.ts | 4 +- src/renderer/src/store/slices/github.ts | 118 +++++- src/renderer/src/store/slices/ui.ts | 2 +- src/shared/constants.ts | 1 + src/shared/task-query.test.ts | 97 +++++ src/shared/task-query.ts | 131 ++++++ src/shared/types.ts | 16 +- src/shared/work-items.ts | 9 + tests/e2e/tasks-page.spec.ts | 54 +++ 20 files changed, 931 insertions(+), 268 deletions(-) create mode 100644 src/renderer/src/components/ui/repo-multi-combobox.tsx create mode 100644 src/shared/task-query.test.ts create mode 100644 src/shared/task-query.ts create mode 100644 src/shared/work-items.ts create mode 100644 tests/e2e/tasks-page.spec.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d2ebc086936..0fc051bb77e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -77,6 +77,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, defaultTaskViewPreset: 'all', + defaultRepoSelection: null, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index ecc5f0ce687..07844477ab6 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -71,6 +71,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, defaultTaskViewPreset: 'all', + defaultRepoSelection: null, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 1e140bf735a..6e00ac00fbc 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { } } -function mapIssueWorkItem(item: Record): 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 + +function mapIssueWorkItem(item: Record): MainWorkItem { return { id: `issue:${String(item.number)}`, type: 'issue', @@ -116,7 +123,7 @@ function mapIssueWorkItem(item: Record): GitHubWorkItem { } } -function mapPullRequestWorkItem(item: Record): GitHubWorkItem { +function mapPullRequestWorkItem(item: Record): MainWorkItem { return { id: `pr:${String(item.number)}`, type: 'pr', @@ -158,115 +165,6 @@ function mapPullRequestWorkItem(item: Record): 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 { +): Promise { 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 { - const fetchers: Promise[] = [] +): Promise { + const fetchers: Promise[] = [] 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 { +): Promise { 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 { +export async function getWorkItem(repoPath: string, number: number): Promise { await acquire() try { const ownerRepo = await getOwnerRepo(repoPath) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 1bec424b85b..df2cb8510e2 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -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 | null = await getWorkItem(repoPath, number) if (!item) { return null } diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 734a18e0a82..db7c6695288 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -356,7 +356,10 @@ export type PreloadApi = { repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise - workItem: (args: { repoPath: string; number: number }) => Promise + workItem: (args: { + repoPath: string + number: number + }) => Promise | null> workItemDetails: (args: { repoPath: string number: number @@ -380,7 +383,7 @@ export type PreloadApi = { repoPath: string limit?: number query?: string - }) => Promise + }) => Promise[]> prChecks: (args: { repoPath: string prNumber: number diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5d7fa804949..68213931828 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -89,7 +89,13 @@ type GhApi = { repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise - workItem: (args: { repoPath: string; number: number }) => Promise + // 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 | null> workItemDetails: (args: { repoPath: string number: number @@ -108,7 +114,7 @@ type GhApi = { repoPath: string limit?: number query?: string - }) => Promise + }) => Promise[]> prChecks: (args: { repoPath: string prNumber: number diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 60bfb46903d..884235cc22b 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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>(() => { 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(resolvedInitialRepoId) + const [repoSelection, setRepoSelection] = useState>(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() + 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(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(() => { - 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 => { - 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` (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 {
-
+
@@ -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' )} > - + @@ -619,19 +698,34 @@ export default function TaskPage(): React.JSX.Element { ) })}
-
- + { + 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" />
{taskSource === 'github' && ( -
+
{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 { - New GitHub issue + {isSingleRepo + ? 'New GitHub issue' + : 'Select a single repo to create an issue'} @@ -710,15 +806,15 @@ export default function TaskPage(): React.JSX.Element {
-
+
- + {taskSearchInput || appliedTaskSearch ? ( @@ -912,7 +1044,7 @@ export default function TaskPage(): React.JSX.Element {
- +
) })}
@@ -946,7 +1078,7 @@ export default function TaskPage(): React.JSX.Element { New GitHub issue - Opens a new issue in {selectedRepo?.displayName ?? 'this repository'}. + Opens a new issue in {primaryRepo?.displayName ?? 'this repository'}.
@@ -985,7 +1117,9 @@ export default function TaskPage(): React.JSX.Element { + + + + + {/* 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. */} +
+ +
+ + No repos match your search. + {filteredRepos.map((repo) => { + const isSelected = selected.has(repo.id) + const isLastSelected = isSelected && selected.size <= 1 + return ( + toggle(repo.id)} + disabled={isLastSelected} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + +
+ + + {repo.connectionId && ( + + + SSH + + )} + +

{repo.path}

+
+
+ ) + })} +
+
+
+ + ) +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 7aa6e961b86..9d1d5fcc7d0 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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(() => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index a1cbb22a400..e845cbdc430 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -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, '') diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index fa8a87ec87f..7354ab22025 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 = { @@ -35,9 +36,42 @@ const inflightPRRequests = new Map< const inflightIssueRequests = new Map>() const inflightChecksRequests = new Map>() const inflightCommentsRequests = new Map>() -const inflightWorkItemsRequests = new Map>() +type InflightWorkItems = { + promise: Promise + force: boolean +} +const inflightWorkItemsRequests = new Map() const prRequestGenerations = new Map() +// 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 { + if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) { + workItemFetchInFlight += 1 + return + } + await new Promise((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 + /** + * 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 = (set, get) => ({ @@ -159,25 +208,40 @@ export const createGitHubSlice: StateCreator = (s return get().workItemsCache[key]?.data ?? null }, - fetchWorkItems: async (repoPath, limit, query, options): Promise => { + fetchWorkItems: async (repoId, repoPath, limit, query, options): Promise => { 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[] + // 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 = (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 = (s return } void get() - .fetchWorkItems(repoPath, limit, query) + .fetchWorkItems(repoId, repoPath, limit, query) .catch(() => {}) }, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7faca505836..8edd54725ca 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -189,7 +189,7 @@ export const createUISlice: StateCreator = (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: () => diff --git a/src/shared/constants.ts b/src/shared/constants.ts index bf6a58be3d5..3dfadef053f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 diff --git a/src/shared/task-query.test.ts b/src/shared/task-query.test.ts new file mode 100644 index 00000000000..5956ff81b7b --- /dev/null +++ b/src/shared/task-query.test.ts @@ -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') + }) +}) diff --git a/src/shared/task-query.ts b/src/shared/task-query.ts new file mode 100644 index 00000000000..968b86d5604 --- /dev/null +++ b/src/shared/task-query.ts @@ -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(' ') +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 7638f5e4600..d556c1d47be 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 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> /** Why: macOS terminals must choose between letting Option compose layout diff --git a/src/shared/work-items.ts b/src/shared/work-items.ts new file mode 100644 index 00000000000..aebcb6058d3 --- /dev/null +++ b/src/shared/work-items.ts @@ -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(items: T[]): T[] { + return [...items].sort((left, right) => { + return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime() + }) +} diff --git a/tests/e2e/tasks-page.spec.ts b/tests/e2e/tasks-page.spec.ts new file mode 100644 index 00000000000..cbb4caf6eb5 --- /dev/null +++ b/tests/e2e/tasks-page.spec.ts @@ -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[0]): Promise { + 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(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(orcaPage, 'activeView') + + await openTasksPage(orcaPage) + await expect + .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) + .toBe('tasks') + + await orcaPage.getByRole('button', { name: 'Close tasks' }).click() + + await expect + .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) + .toBe(previousView) + }) +})