feat(cloud): back from GitHub, the new repository shows up and is picked (REPO-ACCESS-REFRESH)

After 'Add a GitHub repository…' the composer waits for GitHub: 'Waiting for GitHub…' under
Project, the list re-reads when the window regains focus and every 4 s while focused (paused when
hidden), for up to 3 minutes. The first repository that wasn't listed before is picked and Run on
follows it. Picking or clearing a repository ends the wait.
This commit is contained in:
Neil
2026-11-12 16:11:39 -08:00
parent dedecde23c
commit 1ca6aa9da9
9 changed files with 300 additions and 15 deletions
@@ -10,7 +10,7 @@ import type { CloudOnlyRepo } from './run-on/use-cloud-only-repo'
afterEach(cleanup)
function cloudOnly(moreAccess: () => void): CloudOnlyRepo {
function cloudOnly(moreAccess: () => void, waitingForGitHub = false): CloudOnlyRepo {
return {
repos: [],
showMore: null,
@@ -20,11 +20,12 @@ function cloudOnly(moreAccess: () => void): CloudOnlyRepo {
pick: () => {},
clear: () => {},
moreAccess,
waitingForGitHub,
start: async () => false
}
}
function renderSection(recipeId: string | null, moreAccess: () => void): void {
function renderSection(recipeId: string | null, moreAccess: () => void, waiting = false): void {
render(
<TooltipProvider>
<NewWorkspaceComposerProjectSection
@@ -50,7 +51,7 @@ function renderSection(recipeId: string | null, moreAccess: () => void): void {
cloudStart={{
onSelectOrcaCloud: () => {},
onUserPick: () => {},
cloudOnly: cloudOnly(moreAccess)
cloudOnly: cloudOnly(moreAccess, waiting)
}}
handleAddSshHost={() => {}}
handleAddRemoteServer={() => {}}
@@ -92,3 +93,10 @@ describe('the Add project button beside Project (PROJ-MENU part 2, n29)', () =>
expect(screen.queryByText('Add a GitHub repository…')).toBeNull()
})
})
describe('back from GitHub (REPO-ACCESS-REFRESH)', () => {
it('says "Waiting for GitHub…" under Project while the list re-reads', () => {
renderSection(ORCA_CLOUD_RECIPE_ID, vi.fn(), true)
expect(screen.getByRole('status').textContent).toBe('Waiting for GitHub…')
})
})
@@ -8,6 +8,7 @@ import {
import { NewWorkspaceComposerProjectSection } from './NewWorkspaceComposerProjectSection'
import type { EphemeralVmRecipeOption } from './run-target-options'
import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
import type { CloudStartView } from './run-on/cloud-start-view'
const projects: NewWorkspaceProjectOption[] = [
{
@@ -87,17 +88,37 @@ const orcaCloudRecipe: EphemeralVmRecipeOption = {
create: 'orca-cloud create'
}
const waitingCloudStart: CloudStartView = {
onSelectOrcaCloud: () => {},
onUserPick: () => {},
cloudOnly: {
repos: [],
showMore: null,
picked: null,
folderProject: false,
githubStep: null,
pick: () => {},
clear: () => {},
moreAccess: () => {},
waitingForGitHub: true,
start: async () => false
}
}
function ProjectAndHostPreview({
folderOnly = false,
orcaCloud
orcaCloud,
waitingForGitHub = false
}: {
folderOnly?: boolean
/** Back from GitHub's access page (REPO-ACCESS-REFRESH). */
waitingForGitHub?: boolean
/** Offers Orca Cloud with this entitlement. */
orcaCloud?: OrcaCloudRunTargetAccess
}): React.JSX.Element {
const [project, setProject] = useState(folderOnly ? 'folder' : 'orca')
const [host, setHost] = useState('local')
const [recipe, setRecipe] = useState<string | null>(null)
const [recipe, setRecipe] = useState<string | null>(waitingForGitHub ? 'cloud' : null)
return (
<div className="space-y-4 rounded-xl border border-border bg-background p-4">
<h1 className="text-base font-semibold">Create workspace</h1>
@@ -129,6 +150,7 @@ function ProjectAndHostPreview({
selectedEphemeralVmRecipeId={recipe}
onEphemeralVmRecipeChange={setRecipe}
{...(orcaCloud ? { orcaCloudAccess: orcaCloud } : {})}
{...(waitingForGitHub ? { cloudStart: waitingCloudStart } : {})}
handleAddSshHost={() => {}}
handleAddRemoteServer={() => {}}
handleConnectRunTargetHost={async () => {}}
@@ -215,3 +237,13 @@ export const hostMenuOrcaCloudNoPlanMember = defineFixture({
/>
)
})
export const waitingForGitHub = defineFixture({
...closed,
render: () => (
<ProjectAndHostPreview
orcaCloud={{ state: 'entitled', remainingSeconds: 41 * 3600 }}
waitingForGitHub
/>
)
})
@@ -239,6 +239,15 @@ export function NewWorkspaceComposerProjectSection({
<p id={projectDescriptionId} className="text-[11px] text-destructive">
{projectError}
</p>
) : cloudOnly?.waitingForGitHub ? (
// REPO-ACCESS-REFRESH: back from GitHub's access page, until the repository shows up.
<p
role="status"
className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
>
<LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" />
{t('components.newWorkspace.cloudOnly.waitingForGitHub', 'Waiting for GitHub…')}
</p>
) : needsRepo ? (
<OrcaCloudNeedsRepoLine
hasRepos={needsRepo.hasRepos}
@@ -11,6 +11,7 @@ import {
} from '@/lib/orca-cloud/use-orca-cloud-github-repositories'
import { startCloudWorkspaceFromGitHub } from '@/lib/orca-cloud/start-cloud-workspace-from-github'
import type { CloudSetupStep } from './cloud-start-model'
import { useGitHubAccessHandoff } from './use-github-access-handoff'
export type CloudOnlyRepo = {
repos: readonly OrcaCloudGitHubRepository[] | null
@@ -24,6 +25,8 @@ export type CloudOnlyRepo = {
pick: (fullName: string) => void
clear: () => void
moreAccess: () => void
/** Back from GitHub's access page: the list re-reads until the new repository shows up. */
waitingForGitHub: boolean
start: (request: { prompt: string; agent: string | null }) => Promise<boolean>
}
@@ -32,7 +35,8 @@ export function useCloudOnlyRepo({
initialPicked = null,
cloudSelected,
cloudOffered,
signedIn
signedIn,
onAutoPick
}: {
repoId: string
/** `owner/repo` picked when the composer opens (Back to draft from its build sheet). */
@@ -43,12 +47,23 @@ export function useCloudOnlyRepo({
cloudOffered: boolean
/** The list needs an account; signed out, setup comes first. */
signedIn: boolean
/** A repository granted on GitHub was picked for the person: Run on becomes Orca Cloud. */
onAutoPick?: () => void
}): CloudOnlyRepo {
const { folderProject, folderStep } = useFolderGitHubStep(repoId)
// D4 rows show while Orca Cloud is picked, or for a folder, which can only start from GitHub.
const enabled = (cloudSelected || folderProject) && cloudOffered
const { repos, showMore } = useOrcaCloudGitHubRepositories(enabled && signedIn)
const [refreshSignal, setRefreshSignal] = useState(0)
const { repos, showMore } = useOrcaCloudGitHubRepositories(enabled && signedIn, refreshSignal)
const [pickedName, setPickedName] = useState<string | null>(initialPicked)
const handoff = useGitHubAccessHandoff({
repos,
onReread: () => setRefreshSignal((n) => n + 1),
onNewRepository: (fullName) => {
setPickedName(fullName)
onAutoPick?.()
}
})
const picked = useMemo(
() => (enabled ? (repos?.find((repo) => repo.fullName === pickedName) ?? null) : null),
[enabled, pickedName, repos]
@@ -65,11 +80,23 @@ export function useCloudOnlyRepo({
: null,
[picked]
)
const clear = useCallback(() => setPickedName(null), [])
const { begin: beginHandoff, cancel: cancelHandoff } = handoff
const clear = useCallback(() => {
cancelHandoff()
setPickedName(null)
}, [cancelHandoff])
const pick = useCallback(
(fullName: string) => {
cancelHandoff()
setPickedName(fullName)
},
[cancelHandoff]
)
const moreAccess = useCallback(() => {
invalidateOrcaCloudGitHubRepositories()
beginHandoff()
void window.api.orcaCloudWorkspace.connectGitHub()
}, [])
}, [beginHandoff])
const start = useCallback(
(request: { prompt: string; agent: string | null }) =>
picked
@@ -88,9 +115,10 @@ export function useCloudOnlyRepo({
picked,
githubStep: githubStep ?? folderStep,
folderProject,
pick: setPickedName,
pick,
clear,
moreAccess,
waitingForGitHub: enabled && handoff.waiting,
start
}
}
@@ -2,7 +2,7 @@
// dialog: Start either creates the workspace (everything is set up) or saves the draft, closes the
// dialog and opens the full-page setup, which starts the prompt by itself when done. Also: where
// each project last started (O5), the remembered default (D7) and GitHub-only repositories (D4).
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '@/store/index'
import { clearCloudStartDraft, saveCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import {
@@ -89,12 +89,15 @@ export function useCloudStartComposer({
const fetched = useOrcaCloudRunTargetAccess(segment === 'enabled' && !accessOverride)
const access = accessOverride ?? fetched
const githubProjectCount = useAppStore((s) => s.repos.filter(isGitHubBackedProject).length)
const selectOrcaCloudRef = useRef<() => void>(() => {})
const cloudOnly = useCloudOnlyRepo({
repoId,
initialPicked: initialGitHubRepo,
cloudSelected,
cloudOffered: segment === 'enabled',
signedIn: availability !== 'needs-sign-in' && access.state !== 'signed-out'
signedIn: availability !== 'needs-sign-in' && access.state !== 'signed-out',
// A repository granted on GitHub is picked for the person, so Run on follows it.
onAutoPick: () => selectOrcaCloudRef.current()
})
const { steps: rowSteps } = useCloudStartSteps({
availability,
@@ -135,6 +138,9 @@ export function useCloudStartComposer({
markTouched()
setRecipeId?.(ORCA_CLOUD_RECIPE_ID)
}, [markTouched, setRecipeId])
useEffect(() => {
selectOrcaCloudRef.current = onSelectOrcaCloud
}, [onSelectOrcaCloud])
// Start with setup still open: the dialog closes and the page takes over, prompt kept (N6).
const openSetup = useCallback(
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudGitHubRepository } from '@/lib/orca-cloud/use-orca-cloud-github-repositories'
const invalidate = vi.hoisted(() => vi.fn())
vi.mock('@/lib/orca-cloud/use-orca-cloud-github-repositories', () => ({
invalidateOrcaCloudGitHubRepositories: invalidate
}))
import {
GITHUB_HANDOFF_POLL_MS,
GITHUB_HANDOFF_WAIT_MS,
useGitHubAccessHandoff
} from './use-github-access-handoff'
function repo(fullName: string): OrcaCloudGitHubRepository {
return { fullName, url: `https://github.com/${fullName}`, private: true }
}
function setup(initial: OrcaCloudGitHubRepository[] | null) {
const onReread = vi.fn()
const onNewRepository = vi.fn()
const hook = renderHook(
({ repos }: { repos: OrcaCloudGitHubRepository[] | null }) =>
useGitHubAccessHandoff({ repos, onReread, onNewRepository }),
{ initialProps: { repos: initial } }
)
return { hook, onReread, onNewRepository }
}
describe('REPO-ACCESS-REFRESH: back from GitHub, the new repository shows up and is picked', () => {
let focused = true
beforeEach(() => {
vi.useFakeTimers()
focused = true
vi.spyOn(document, 'hasFocus').mockImplementation(() => focused)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
invalidate.mockReset()
})
it('re-reads on focus and picks the repository that was not listed before', () => {
const { hook, onReread, onNewRepository } = setup([repo('acme/api')])
act(() => hook.result.current.begin())
expect(hook.result.current.waiting).toBe(true)
act(() => {
window.dispatchEvent(new Event('focus'))
})
expect(invalidate).toHaveBeenCalled()
expect(onReread).toHaveBeenCalledOnce()
// The same list again: still waiting.
hook.rerender({ repos: [repo('acme/api')] })
expect(hook.result.current.waiting).toBe(true)
hook.rerender({ repos: [repo('acme/api'), repo('acme/web')] })
expect(onNewRepository).toHaveBeenCalledWith('acme/web')
expect(hook.result.current.waiting).toBe(false)
})
it('polls only while this window has focus, and gives up after a while', () => {
const { hook, onReread, onNewRepository } = setup([])
act(() => hook.result.current.begin())
focused = false
act(() => vi.advanceTimersByTime(GITHUB_HANDOFF_POLL_MS * 3))
expect(onReread).not.toHaveBeenCalled()
focused = true
act(() => vi.advanceTimersByTime(GITHUB_HANDOFF_POLL_MS))
expect(onReread).toHaveBeenCalledOnce()
act(() => vi.advanceTimersByTime(GITHUB_HANDOFF_WAIT_MS))
expect(hook.result.current.waiting).toBe(false)
const calls = onReread.mock.calls.length
act(() => vi.advanceTimersByTime(GITHUB_HANDOFF_POLL_MS * 2))
expect(onReread.mock.calls.length).toBe(calls)
expect(onNewRepository).not.toHaveBeenCalled()
})
it('takes the first answer as the baseline when the list had not loaded yet', () => {
const { hook, onNewRepository } = setup(null)
act(() => hook.result.current.begin())
hook.rerender({ repos: [repo('acme/api')] })
expect(onNewRepository).not.toHaveBeenCalled()
hook.rerender({ repos: [repo('acme/api'), repo('acme/new')] })
expect(onNewRepository).toHaveBeenCalledWith('acme/new')
})
it('stops waiting when the person picks or clears on their own', () => {
const { hook, onNewRepository } = setup([repo('acme/api')])
act(() => hook.result.current.begin())
act(() => hook.result.current.cancel())
hook.rerender({ repos: [repo('acme/api'), repo('acme/web')] })
expect(onNewRepository).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
// REPO-ACCESS-REFRESH: after "Add a GitHub repository…" the person grants access on GitHub and
// comes back. The list reads GitHub again when this window regains focus, and every few seconds
// while it has focus, until a repository shows up that wasn't listed before. That one is picked.
import { useCallback, useEffect, useRef, useState } from 'react'
import {
invalidateOrcaCloudGitHubRepositories,
type OrcaCloudGitHubRepository
} from '@/lib/orca-cloud/use-orca-cloud-github-repositories'
import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval'
/** While focused, ask GitHub again this often (GitHub's install webhook can lag the redirect). */
export const GITHUB_HANDOFF_POLL_MS = 4_000
/** Stop waiting after this long: the person may have closed GitHub without granting anything. */
export const GITHUB_HANDOFF_WAIT_MS = 3 * 60_000
function names(repos: readonly OrcaCloudGitHubRepository[]): Set<string> {
return new Set(repos.map((repo) => repo.fullName))
}
export function useGitHubAccessHandoff({
repos,
onReread,
onNewRepository
}: {
repos: readonly OrcaCloudGitHubRepository[] | null
/** Read the list again (its cache was just dropped). */
onReread: () => void
onNewRepository: (fullName: string) => void
}): {
waiting: boolean
begin: () => void
cancel: () => void
} {
const [waiting, setWaiting] = useState(false)
// Listed before the handoff; null when the list hadn't loaded yet (the next answer is the baseline).
const knownRef = useRef<ReadonlySet<string> | null>(null)
const reposRef = useRef(repos)
const onNewRef = useRef(onNewRepository)
const onRereadRef = useRef(onReread)
useEffect(() => {
reposRef.current = repos
onNewRef.current = onNewRepository
onRereadRef.current = onReread
})
const begin = useCallback(() => {
knownRef.current = reposRef.current ? names(reposRef.current) : null
setWaiting(true)
}, [])
const cancel = useCallback(() => setWaiting(false), [])
useEffect(() => {
if (!waiting) {
return
}
const reread = (): void => {
invalidateOrcaCloudGitHubRepositories()
onRereadRef.current()
}
// Away on GitHub, polling would only spend requests; coming back fires focus.
const stopPolling = installWindowVisibilityInterval({
run: () => {
if (document.hasFocus()) {
reread()
}
},
// Not on install (the person is leaving for GitHub) or on becoming visible: focus covers the return.
runOnVisible: () => {},
intervalMs: GITHUB_HANDOFF_POLL_MS
})
const giveUp = window.setTimeout(cancel, GITHUB_HANDOFF_WAIT_MS)
window.addEventListener('focus', reread)
return () => {
stopPolling()
window.clearTimeout(giveUp)
window.removeEventListener('focus', reread)
}
}, [cancel, waiting])
// Each answer while waiting: the first one may only set the baseline; a new name is the grant.
useEffect(() => {
if (!waiting || !repos) {
return
}
const known = knownRef.current
if (!known) {
knownRef.current = names(repos)
return
}
const added = repos.find((repo) => !known.has(repo.fullName))
if (added) {
// The list is GitHub's answer to a re-read, not a prop to mirror: the wait ends with it.
// oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change
setWaiting(false)
onNewRef.current(added.fullName)
}
}, [repos, waiting])
return { waiting, begin, cancel }
}
+2 -1
View File
@@ -23928,7 +23928,8 @@
"rowPublic": "{{repo}}, cloud only",
"privateLabel": "Private",
"menuGroup": "Orca Cloud",
"addRepositoryMenu": "Add a GitHub repository…"
"addRepositoryMenu": "Add a GitHub repository…",
"waitingForGitHub": "Waiting for GitHub…"
},
"agentLabel": "Agent"
},
@@ -93,7 +93,11 @@ export function selectCloudOnlyRepositories(
.sort((a, b) => a.fullName.localeCompare(b.fullName))
}
export function useOrcaCloudGitHubRepositories(enabled: boolean): {
/** `refreshSignal`: each change reads the list again (after the cache was invalidated). */
export function useOrcaCloudGitHubRepositories(
enabled: boolean,
refreshSignal = 0
): {
repos: OrcaCloudGitHubRepository[] | null
/** Present while GitHub has more pages than are listed. */
showMore: (() => void) | null
@@ -116,7 +120,7 @@ export function useOrcaCloudGitHubRepositories(enabled: boolean): {
return () => {
cancelled = true
}
}, [enabled, identity, moreRequests])
}, [enabled, identity, moreRequests, refreshSignal])
const visible = useMemo(
() =>
enabled && listing