From 1ca6aa9da9191a42f4f3435d7302bc68eaceec48 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 15:11:46 -0800 Subject: [PATCH] feat(cloud): back from GitHub, the new repository shows up and is picked (REPO-ACCESS-REFRESH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...omposerProjectSection.add-project.test.tsx | 14 ++- ...orkspaceComposerProjectSection.fixture.tsx | 36 ++++++- .../NewWorkspaceComposerProjectSection.tsx | 9 ++ .../run-on/use-cloud-only-repo.ts | 38 ++++++- .../run-on/use-cloud-start-composer.ts | 10 +- .../run-on/use-github-access-handoff.test.tsx | 97 +++++++++++++++++ .../run-on/use-github-access-handoff.ts | 100 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 3 +- .../use-orca-cloud-github-repositories.ts | 8 +- 9 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.test.tsx create mode 100644 src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.ts diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.add-project.test.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.add-project.test.tsx index e6a2a7a3dd3..868bb106702 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.add-project.test.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.add-project.test.tsx @@ -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( 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…') + }) +}) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx index 2ad2dd65ce9..14dd3267d5a 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx @@ -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(null) + const [recipe, setRecipe] = useState(waitingForGitHub ? 'cloud' : null) return (

Create workspace

@@ -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: () => ( + + ) +}) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index 41df43cc434..46c9bae382a 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -239,6 +239,15 @@ export function NewWorkspaceComposerProjectSection({

{projectError}

+ ) : cloudOnly?.waitingForGitHub ? ( + // REPO-ACCESS-REFRESH: back from GitHub's access page, until the repository shows up. +

+ + {t('components.newWorkspace.cloudOnly.waitingForGitHub', 'Waiting for GitHub…')} +

) : needsRepo ? ( 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 } @@ -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(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 } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index 8980071e0ee..002eadb5864 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -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( diff --git a/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.test.tsx b/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.test.tsx new file mode 100644 index 00000000000..12438ff4d69 --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.test.tsx @@ -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() + }) +}) diff --git a/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.ts b/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.ts new file mode 100644 index 00000000000..a3cb353ae2f --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-on/use-github-access-handoff.ts @@ -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 { + 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 | 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 } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cc993c34766..3ad2a53ce22 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" }, diff --git a/src/renderer/src/lib/orca-cloud/use-orca-cloud-github-repositories.ts b/src/renderer/src/lib/orca-cloud/use-orca-cloud-github-repositories.ts index 4e7f77f0780..c684f2e03ee 100644 --- a/src/renderer/src/lib/orca-cloud/use-orca-cloud-github-repositories.ts +++ b/src/renderer/src/lib/orca-cloud/use-orca-cloud-github-repositories.ts @@ -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