From aec7bb6aa09f0d2eefe078234613ebed75cce7e6 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 23 Jun 2026 10:22:54 -0700
Subject: [PATCH] Scope GitHub Project rows to selected repos (#6174)
* Scope GitHub Project rows to selected repos
Adds the selected-repo Project scoping implementation and documents the UI behavior in docs/github-project-selected-repo-popup.md.
* rm design doc
* fix: address review findings
---
src/renderer/src/components/TaskPage.tsx | 151 ++++++++--------
.../github-project/ProjectViewWrapper.tsx | 165 +++++++++++++++---
.../project-dialog-state.test.ts | 61 ++++++-
.../github-project/project-dialog-state.ts | 44 ++++-
.../project-row-filtering.test.ts | 116 +++++++++++-
.../github-project/project-row-filtering.ts | 69 ++++++++
.../project-visible-table-cache.test.ts | 37 +++-
.../project-visible-table-cache.ts | 27 ++-
.../task-page-source-switch-boundary.test.ts | 3 +-
src/renderer/src/i18n/locales/en.json | 5 +-
src/renderer/src/i18n/locales/es.json | 16 +-
src/renderer/src/i18n/locales/ja.json | 16 +-
src/renderer/src/i18n/locales/ko.json | 16 +-
src/renderer/src/i18n/locales/zh.json | 16 +-
14 files changed, 603 insertions(+), 139 deletions(-)
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 92c6ebe55..e9495d4c3 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -7762,91 +7762,84 @@ export default function TaskPage(): React.JSX.Element {
})}
) : null}
- {/* Why: the repo combobox filters Items mode by repo. In
- Project mode the row set comes from the project's
- view filter (server-side), so this control would be
- inert — hide it to avoid suggesting it does
- something. */}
- {githubMode !== 'project' && (
- <>
-
-
{
- const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
- setRepoSelection(normalized)
- void updateSettings({ defaultRepoSelection: [...normalized] }).catch(
- () => {
- toast.error(
- translate(
- 'auto.components.TaskPage.dfd72673e7',
- 'Failed to save project selection.'
- )
- )
- }
- )
- }}
- onSelectAll={() => {
- const allIds = new Set(taskPickerRepos.map((r) => r.id))
- setRepoSelection(allIds)
- void updateSettings({ defaultRepoSelection: null }).catch(() => {
- toast.error(
- translate(
- 'auto.components.TaskPage.dfd72673e7',
- 'Failed to save project selection.'
- )
+ {/* Why: Project rows are now repo-scoped too, so the
+ selection must stay visible in both GitHub modes. */}
+
+ {
+ const normalized = normalizeTaskRepoSelection(eligibleRepos, next)
+ setRepoSelection(normalized)
+ void updateSettings({ defaultRepoSelection: [...normalized] }).catch(
+ () => {
+ toast.error(
+ translate(
+ 'auto.components.TaskPage.dfd72673e7',
+ 'Failed to save project selection.'
)
- })
- }}
- triggerClassName="h-8 w-auto max-w-[220px] 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"
- />
-
-
-
-
-
-
- {selectedGitHubRepoExternalLink
+ )
+ }
+ )
+ }}
+ onSelectAll={() => {
+ const allIds = new Set(taskPickerRepos.map((r) => r.id))
+ setRepoSelection(allIds)
+ void updateSettings({ defaultRepoSelection: null }).catch(() => {
+ toast.error(
+ translate(
+ 'auto.components.TaskPage.dfd72673e7',
+ 'Failed to save project selection.'
+ )
+ )
+ })
+ }}
+ triggerClassName="h-8 w-auto max-w-[220px] 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"
+ />
+
+
+
+
- >
- )}
+ 'auto.components.TaskPage.d1132848f8',
+ 'Select one GitHub project to open in GitHub'
+ )
+ }
+ className="h-8 w-8 rounded-md border-border/50 bg-muted/50 text-foreground shadow-sm transition hover:bg-muted/50"
+ >
+
+
+
+
+ {selectedGitHubRepoExternalLink
+ ? translate(
+ 'auto.components.TaskPage.8d1e17a3ef',
+ 'Open {{value0}} in GitHub',
+ { value0: selectedGitHubRepoExternalLink.label }
+ )
+ : translate(
+ 'auto.components.TaskPage.bc46d8204e',
+ 'Select one project to open in GitHub'
+ )}
+
+
) : null}
@@ -8635,7 +8628,7 @@ export default function TaskPage(): React.JSX.Element {
)
) : taskSource === 'github' && githubMode === 'project' ? (
) : taskSource === 'github' ? (
diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
index f76361d36..705d30607 100644
--- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
+++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
@@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types'
import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker'
import ProjectViewList from './ProjectViewList'
import ProjectItemSlugDialog from './ProjectItemSlugDialog'
-import { filterProjectTableRowsByOpenRepos } from './project-row-filtering'
+import {
+ filterProjectTableRowsBySelectedRepos,
+ resolveSelectedProjectRowRepo
+} from './project-row-filtering'
import {
resolveMissingRepoProjectDialogState,
resolveRepoBackedProjectDialogState
} from './project-dialog-state'
import {
+ getSelectedRepoFingerprint,
getNextVisibleProjectTableCache,
getVisibleProjectTable,
type CachedVisibleProjectTable
} from './project-visible-table-cache'
import { translate } from '@/i18n/i18n'
-type Props = Record
+type Props = {
+ selectedRepoIds: ReadonlySet
+}
const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new'
@@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters s.settings)
const projectViewCache = useAppStore((s) => s.projectViewCache)
const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable)
@@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const table: GitHubProjectTable | null = currentCacheKey
? (projectViewCache[currentCacheKey]?.data ?? null)
: null
+ const selectedRepoFingerprint = useMemo(
+ () => getSelectedRepoFingerprint(selectedRepoIds),
+ [selectedRepoIds]
+ )
const filteredTable = useMemo(
- () => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null),
- [table, slugIndexReady, lookupSlug]
+ () =>
+ table && slugIndexReady
+ ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds)
+ : null,
+ [table, slugIndexReady, lookupSlug, selectedRepoIds]
)
const lastFilteredTableRef = useRef(null)
// Why: this cache only prevents a blank table while the repo slug index
@@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
// a second render after every filtered-table change.
lastFilteredTableRef.current = getNextVisibleProjectTableCache({
currentCacheKey,
+ selectedRepoFingerprint,
sourceTable: table,
slugIndexReady,
filteredTable,
@@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
})
const visibleTable = getVisibleProjectTable({
currentCacheKey,
+ selectedRepoFingerprint,
slugIndexReady,
filteredTable,
cachedTable: lastFilteredTableRef.current
@@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
} | null>(null)
const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos])
- const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds)
+ const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(
+ dialogRepoItem,
+ liveRepoIds,
+ selectedRepoIds
+ )
if (resolvedDialogRepoItem !== dialogRepoItem) {
// Why: repo-backed Project dialogs cannot edit after their repo leaves
// Orca; clear them before the modal tree receives stale repo ids.
@@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
slugIndexReady,
slugDialog,
repoNotInOrca,
- lookupSlug
+ lookupSlug,
+ selectedRepoIds
})
if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) {
// Why: once a previously missing repo is registered, Project rows should
@@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
[]
)
+ const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => {
+ if (row.content.url) {
+ void window.api.shell.openUrl(row.content.url)
+ }
+ toast.message(message)
+ }, [])
+
const handleOpenDialog = useCallback(
(row: GitHubProjectRow) => {
if (!currentCacheKey || !table) {
@@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
}
return
}
- const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
- const matched = matches.length === 1 ? matches[0] : null
- if (matched) {
- const workItem = buildWorkItem(row, matched.id)
+ const resolution = resolveSelectedProjectRowRepo({
+ row,
+ lookupSlug,
+ slugIndexReady,
+ selectedRepoIds
+ })
+ if (resolution.status === 'loading') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.f352abf7c3',
+ 'Repository list is updating.'
+ )
+ )
+ return
+ }
+ if (resolution.status === 'selected_match') {
+ const workItem = buildWorkItem(row, resolution.repo.id)
if (workItem) {
- setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin })
+ setDialogRepoItem({
+ workItem,
+ repoPath: resolution.repo.path,
+ repoId: resolution.repo.id,
+ origin
+ })
return
}
}
- // Unknown repo — use the simplified slug-mode dialog.
- setSlugDialog({ origin })
+ if (resolution.status === 'no_global_match') {
+ // Unknown repo — use the simplified slug-mode dialog.
+ setSlugDialog({ origin })
+ return
+ }
+ if (resolution.status === 'unselected_match') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff',
+ 'This item is outside the selected repositories.'
+ )
+ )
+ return
+ }
+ if (resolution.status === 'ambiguous_selected_match') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.030de75bc5',
+ 'This item matches multiple selected repositories.'
+ )
+ )
+ }
},
- [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem]
+ [
+ currentCacheKey,
+ table,
+ buildOrigin,
+ lookupSlug,
+ slugIndexReady,
+ selectedRepoIds,
+ openProjectRowUrlWithToast,
+ buildWorkItem
+ ]
)
const handleStartWork = useCallback(
@@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!origin) {
return
}
- const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
- const matched = matches.length === 1 ? matches[0] : null
- if (!matched) {
+ const resolution = resolveSelectedProjectRowRepo({
+ row,
+ lookupSlug,
+ slugIndexReady,
+ selectedRepoIds
+ })
+ if (resolution.status === 'loading') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.f352abf7c3',
+ 'Repository list is updating.'
+ )
+ )
+ return
+ }
+ if (resolution.status === 'no_global_match') {
setRepoNotInOrca({
owner: origin.owner,
repo: origin.repo,
@@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
})
return
}
- const workItem = buildWorkItem(row, matched.id)
+ if (resolution.status === 'unselected_match') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff',
+ 'This item is outside the selected repositories.'
+ )
+ )
+ return
+ }
+ if (resolution.status === 'ambiguous_selected_match') {
+ openProjectRowUrlWithToast(
+ row,
+ translate(
+ 'auto.components.github.project.ProjectViewWrapper.030de75bc5',
+ 'This item matches multiple selected repositories.'
+ )
+ )
+ return
+ }
+ if (resolution.status !== 'selected_match') {
+ return
+ }
+ const workItem = buildWorkItem(row, resolution.repo.id)
if (!workItem) {
return
}
void launchWorkItemDirect({
item: workItem,
- repoId: matched.id,
+ repoId: resolution.repo.id,
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: () => {
@@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
}
})
},
- [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem]
+ [
+ currentCacheKey,
+ table,
+ buildOrigin,
+ lookupSlug,
+ slugIndexReady,
+ selectedRepoIds,
+ openProjectRowUrlWithToast,
+ buildWorkItem
+ ]
)
const handleEditAssignees = useCallback(
diff --git a/src/renderer/src/components/github-project/project-dialog-state.test.ts b/src/renderer/src/components/github-project/project-dialog-state.test.ts
index 78aa6b127..99afa2388 100644
--- a/src/renderer/src/components/github-project/project-dialog-state.test.ts
+++ b/src/renderer/src/components/github-project/project-dialog-state.test.ts
@@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => {
it('keeps a repo-backed dialog when the repo still exists', () => {
const dialog = { repoId: 'repo-1', label: 'Issue 1' }
- expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog)
+ expect(
+ resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1']))
+ ).toBe(dialog)
})
it('clears a repo-backed dialog when its repo is removed', () => {
expect(
- resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2']))
+ resolveRepoBackedProjectDialogState(
+ { repoId: 'repo-1' },
+ new Set(['repo-2']),
+ new Set(['repo-1'])
+ )
+ ).toBeNull()
+ })
+
+ it('clears a repo-backed dialog when its repo is no longer selected', () => {
+ expect(
+ resolveRepoBackedProjectDialogState(
+ { repoId: 'repo-1' },
+ new Set(['repo-1']),
+ new Set(['repo-2'])
+ )
).toBeNull()
})
})
describe('resolveMissingRepoProjectDialogState', () => {
- it('waits for the slug index before closing missing-repo dialogs', () => {
+ it('clears fallback dialogs while the slug index is rebuilding', () => {
const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
@@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: false,
slugDialog,
repoNotInOrca,
- lookupSlug: () => ['repo-1']
+ lookupSlug: () => [{ id: 'repo-1' }],
+ selectedRepoIds: new Set(['repo-1'])
})
- ).toEqual({ slugDialog, repoNotInOrca })
+ ).toEqual({ slugDialog: null, repoNotInOrca: null })
})
it('clears slug fallback dialogs once the repo slug resolves', () => {
@@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: true,
slugDialog,
repoNotInOrca,
- lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : [])
+ lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []),
+ selectedRepoIds: new Set(['repo-1'])
})
expect(result.slugDialog).toBeNull()
@@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => {
slugIndexReady: true,
slugDialog,
repoNotInOrca,
- lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : [])
+ lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []),
+ selectedRepoIds: new Set(['repo-1'])
})
expect(result.slugDialog).toBe(slugDialog)
expect(result.repoNotInOrca).toBeNull()
})
+
+ it('clears fallback dialogs when the repo is globally known but not selected', () => {
+ const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
+ const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
+ const result = resolveMissingRepoProjectDialogState({
+ slugIndexReady: true,
+ slugDialog,
+ repoNotInOrca,
+ lookupSlug: () => [{ id: 'repo-2' }],
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(result).toEqual({ slugDialog: null, repoNotInOrca: null })
+ })
+
+ it('keeps missing-repo fallback dialogs when there are no global matches', () => {
+ const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } }
+ const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null }
+ const result = resolveMissingRepoProjectDialogState({
+ slugIndexReady: true,
+ slugDialog,
+ repoNotInOrca,
+ lookupSlug: () => [],
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(result).toEqual({ slugDialog, repoNotInOrca })
+ })
})
diff --git a/src/renderer/src/components/github-project/project-dialog-state.ts b/src/renderer/src/components/github-project/project-dialog-state.ts
index cb092bcae..88f71c9fc 100644
--- a/src/renderer/src/components/github-project/project-dialog-state.ts
+++ b/src/renderer/src/components/github-project/project-dialog-state.ts
@@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = {
repo: string
}
-type LookupSlug = (slug: string) => readonly unknown[]
+type RepoMatch = {
+ id: string
+}
-function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean {
- return lookupSlug(`${owner}/${repo}`).length > 0
+type LookupSlug = (slug: string) => readonly RepoMatch[]
+
+function shouldCloseFallbackDialog(args: {
+ lookupSlug: LookupSlug
+ selectedRepoIds: ReadonlySet
+ owner: string
+ repo: string
+}): boolean {
+ const matches = args.lookupSlug(`${args.owner}/${args.repo}`)
+ const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length
+ const unselectedMatchCount = matches.length - selectedMatchCount
+ return selectedMatchCount > 0 || unselectedMatchCount > 0
}
export function resolveRepoBackedProjectDialogState(
dialog: T | null,
- liveRepoIds: ReadonlySet
+ liveRepoIds: ReadonlySet,
+ selectedRepoIds: ReadonlySet
): T | null {
- if (dialog && !liveRepoIds.has(dialog.repoId)) {
+ if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) {
return null
}
return dialog
@@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState<
slugDialog: TSlugDialog | null
repoNotInOrca: TRepoNotInOrca | null
lookupSlug: LookupSlug
+ selectedRepoIds: ReadonlySet
}): {
slugDialog: TSlugDialog | null
repoNotInOrca: TRepoNotInOrca | null
} {
- const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args
+ const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args
if (!slugIndexReady) {
- return { slugDialog, repoNotInOrca }
+ return { slugDialog: null, repoNotInOrca: null }
}
return {
slugDialog:
- slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo)
+ slugDialog &&
+ shouldCloseFallbackDialog({
+ lookupSlug,
+ selectedRepoIds,
+ owner: slugDialog.origin.owner,
+ repo: slugDialog.origin.repo
+ })
? null
: slugDialog,
repoNotInOrca:
- repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo)
+ repoNotInOrca &&
+ shouldCloseFallbackDialog({
+ lookupSlug,
+ selectedRepoIds,
+ owner: repoNotInOrca.owner,
+ repo: repoNotInOrca.repo
+ })
? null
: repoNotInOrca
}
diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts
index fb061c262..f70b44be4 100644
--- a/src/renderer/src/components/github-project/project-row-filtering.test.ts
+++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types'
import type { Repo } from '../../../../shared/types'
-import { filterProjectTableRowsByOpenRepos } from './project-row-filtering'
+import {
+ filterProjectTableRowsByOpenRepos,
+ filterProjectTableRowsBySelectedRepos,
+ resolveSelectedProjectRowRepo
+} from './project-row-filtering'
function repo(id: string): Repo {
return {
@@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => {
expect(filtered.totalCount).toBe(0)
})
})
+
+describe('filterProjectTableRowsBySelectedRepos', () => {
+ it('keeps a row when at least one slug match is selected', () => {
+ const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')]
+ const filtered = filterProjectTableRowsBySelectedRepos(
+ table(rows),
+ (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]),
+ true,
+ new Set(['repo-1'])
+ )
+
+ expect(filtered.rows.map((r) => r.id)).toEqual(['visible'])
+ expect(filtered.totalCount).toBe(1)
+ })
+
+ it('filters a row when only unselected repos match', () => {
+ const rows = [row('hidden', 'acme/orca')]
+ const filtered = filterProjectTableRowsBySelectedRepos(
+ table(rows),
+ () => [repo('repo-2')],
+ true,
+ new Set(['repo-1'])
+ )
+
+ expect(filtered.rows).toEqual([])
+ expect(filtered.totalCount).toBe(0)
+ })
+
+ it('keeps a row with multiple selected matches for action ambiguity handling', () => {
+ const rows = [row('ambiguous', 'acme/orca')]
+ const filtered = filterProjectTableRowsBySelectedRepos(
+ table(rows),
+ () => [repo('repo-1'), repo('repo-2'), repo('repo-3')],
+ true,
+ new Set(['repo-1', 'repo-2'])
+ )
+
+ expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous'])
+ })
+})
+
+describe('resolveSelectedProjectRowRepo', () => {
+ it('reports loading without reading stale slug matches', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('loading', 'acme/orca'),
+ lookupSlug: () => {
+ throw new Error('should not read stale matches')
+ },
+ slugIndexReady: false,
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(resolution.status).toBe('loading')
+ })
+
+ it('reports invalid slug for rows without a repository', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('missing-slug', null),
+ lookupSlug: () => [repo('repo-1')],
+ slugIndexReady: true,
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(resolution.status).toBe('invalid_slug')
+ })
+
+ it('reports no global match when Orca has no repo for the slug', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('missing', 'acme/orca'),
+ lookupSlug: () => [],
+ slugIndexReady: true,
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(resolution.status).toBe('no_global_match')
+ })
+
+ it('reports global-only matches when the repo is not selected', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('unselected', 'acme/orca'),
+ lookupSlug: () => [repo('repo-2')],
+ slugIndexReady: true,
+ selectedRepoIds: new Set(['repo-1'])
+ })
+
+ expect(resolution.status).toBe('unselected_match')
+ })
+
+ it('returns the selected match when exactly one matching repo is selected', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('selected', 'acme/orca'),
+ lookupSlug: () => [repo('repo-1'), repo('repo-2')],
+ slugIndexReady: true,
+ selectedRepoIds: new Set(['repo-2'])
+ })
+
+ expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } })
+ })
+
+ it('reports ambiguity when multiple matching repos are selected', () => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row: row('ambiguous', 'acme/orca'),
+ lookupSlug: () => [repo('repo-1'), repo('repo-2')],
+ slugIndexReady: true,
+ selectedRepoIds: new Set(['repo-1', 'repo-2'])
+ })
+
+ expect(resolution.status).toBe('ambiguous_selected_match')
+ })
+})
diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts
index 5e5f90d11..a0c23c20d 100644
--- a/src/renderer/src/components/github-project/project-row-filtering.ts
+++ b/src/renderer/src/components/github-project/project-row-filtering.ts
@@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types'
export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[]
+export type SelectedProjectRowResolution =
+ | { status: 'loading' }
+ | { status: 'invalid_slug' }
+ | { status: 'no_global_match' }
+ | { status: 'unselected_match'; globalMatches: readonly Repo[] }
+ | { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] }
+ | {
+ status: 'ambiguous_selected_match'
+ selectedMatches: readonly Repo[]
+ globalMatches: readonly Repo[]
+ }
+
+export function resolveSelectedProjectRowRepo(input: {
+ row: GitHubProjectRow
+ lookupSlug: ProjectRowSlugLookup
+ slugIndexReady: boolean
+ selectedRepoIds: ReadonlySet
+}): SelectedProjectRowResolution {
+ if (!input.slugIndexReady) {
+ return { status: 'loading' }
+ }
+
+ const repository = input.row.content.repository
+ if (!repository) {
+ return { status: 'invalid_slug' }
+ }
+ const [owner, repo] = repository.split('/')
+ if (!owner || !repo) {
+ return { status: 'invalid_slug' }
+ }
+
+ const globalMatches = input.lookupSlug(repository)
+ if (globalMatches.length === 0) {
+ return { status: 'no_global_match' }
+ }
+
+ const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id))
+ if (selectedMatches.length === 0) {
+ return { status: 'unselected_match', globalMatches }
+ }
+ if (selectedMatches.length === 1) {
+ return { status: 'selected_match', repo: selectedMatches[0], globalMatches }
+ }
+ return { status: 'ambiguous_selected_match', selectedMatches, globalMatches }
+}
+
export function projectRowHasOpenRepo(
row: GitHubProjectRow,
lookupSlug: ProjectRowSlugLookup
@@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos(
}
return { ...table, rows, totalCount: rows.length }
}
+
+export function filterProjectTableRowsBySelectedRepos(
+ table: GitHubProjectTable,
+ lookupSlug: ProjectRowSlugLookup,
+ slugIndexReady: boolean,
+ selectedRepoIds: ReadonlySet
+): GitHubProjectTable {
+ const rows = table.rows.filter((row) => {
+ const resolution = resolveSelectedProjectRowRepo({
+ row,
+ lookupSlug,
+ slugIndexReady,
+ selectedRepoIds
+ })
+ return (
+ resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match'
+ )
+ })
+ if (rows.length === table.rows.length && table.totalCount === rows.length) {
+ return table
+ }
+ return { ...table, rows, totalCount: rows.length }
+}
diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts
index e2bc44cca..e5eeabc40 100644
--- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts
+++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { GitHubProjectTable } from '../../../../shared/github-project-types'
import {
+ getSelectedRepoFingerprint,
getNextVisibleProjectTableCache,
- getVisibleProjectTable
+ getVisibleProjectTable,
+ getVisibleProjectTableCacheKey
} from './project-visible-table-cache'
function table(id: string): GitHubProjectTable {
@@ -17,20 +19,22 @@ describe('project visible table cache', () => {
expect(
getNextVisibleProjectTableCache({
currentCacheKey: 'project:view',
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable,
slugIndexReady: true,
filteredTable,
previous: null
})
- ).toEqual({ cacheKey: 'project:view', table: filteredTable })
+ ).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable })
})
it('keeps the previous cache while the slug index is rebuilding', () => {
- const previous = { cacheKey: 'project:view', table: table('previous') }
+ const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') }
expect(
getNextVisibleProjectTableCache({
currentCacheKey: 'project:view',
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable: table('source'),
slugIndexReady: false,
filteredTable: null,
@@ -45,6 +49,7 @@ describe('project visible table cache', () => {
expect(
getNextVisibleProjectTableCache({
currentCacheKey: null,
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
sourceTable: null,
slugIndexReady: false,
filteredTable: null,
@@ -54,11 +59,12 @@ describe('project visible table cache', () => {
})
it('shows a matching cached table while the slug index is rebuilding', () => {
- const cachedTable = { cacheKey: 'project:view', table: table('cached') }
+ const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') }
expect(
getVisibleProjectTable({
currentCacheKey: 'project:view',
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
slugIndexReady: false,
filteredTable: null,
cachedTable
@@ -67,15 +73,36 @@ describe('project visible table cache', () => {
})
it('does not show stale cached data for a different cache key', () => {
- const cachedTable = { cacheKey: 'other:view', table: table('cached') }
+ const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') }
expect(
getVisibleProjectTable({
currentCacheKey: 'project:view',
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])),
slugIndexReady: false,
filteredTable: null,
cachedTable
})
).toBeNull()
})
+
+ it('does not show stale cached data for a different repo selection', () => {
+ const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') }
+
+ expect(
+ getVisibleProjectTable({
+ currentCacheKey: 'project:view',
+ selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])),
+ slugIndexReady: false,
+ filteredTable: null,
+ cachedTable
+ })
+ ).toBeNull()
+ })
+
+ it('layers selection only onto the renderer visible-table cache key', () => {
+ const storeCacheKey = 'organization:stablyai:1:view-id:local'
+
+ expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey)
+ })
})
diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts
index 241101225..6390e8972 100644
--- a/src/renderer/src/components/github-project/project-visible-table-cache.ts
+++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts
@@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = {
table: GitHubProjectTable
}
+export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet): string {
+ return JSON.stringify([...selectedRepoIds].sort())
+}
+
+export function getVisibleProjectTableCacheKey(
+ currentCacheKey: string | null,
+ selectedRepoFingerprint: string
+): string | null {
+ return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null
+}
+
export function getNextVisibleProjectTableCache(input: {
currentCacheKey: string | null
+ selectedRepoFingerprint: string
sourceTable: GitHubProjectTable | null
slugIndexReady: boolean
filteredTable: GitHubProjectTable | null
previous: CachedVisibleProjectTable | null
}): CachedVisibleProjectTable | null {
- if (!input.currentCacheKey || !input.sourceTable) {
+ const visibleCacheKey = getVisibleProjectTableCacheKey(
+ input.currentCacheKey,
+ input.selectedRepoFingerprint
+ )
+ if (!visibleCacheKey || !input.sourceTable) {
return null
}
if (input.slugIndexReady && input.filteredTable) {
- return { cacheKey: input.currentCacheKey, table: input.filteredTable }
+ return { cacheKey: visibleCacheKey, table: input.filteredTable }
}
return input.previous
}
export function getVisibleProjectTable(input: {
currentCacheKey: string | null
+ selectedRepoFingerprint: string
slugIndexReady: boolean
filteredTable: GitHubProjectTable | null
cachedTable: CachedVisibleProjectTable | null
@@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: {
if (input.slugIndexReady || !input.currentCacheKey) {
return input.filteredTable
}
- return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null
+ const visibleCacheKey = getVisibleProjectTableCacheKey(
+ input.currentCacheKey,
+ input.selectedRepoFingerprint
+ )
+ return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null
}
diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts
index a694b99c4..1b1d98786 100644
--- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts
+++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts
@@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => {
)
const modalSection = sourceBetween(
TASK_PAGE_SOURCE,
- '',
+ '',
'