diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index be4d89b4d8e..257823564bc 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -129,7 +129,7 @@ type AccountState = { export { entitlementsMode, type EntitlementsMode } from './entitlements-mode' -const LIVE_STATUSES = new Set(['active', 'trialing']) +export const LIVE_STATUSES = new Set(['active', 'trialing']) export function subscriptionWindow(subscription: SubscriptionRow, at: number): { start: number; end: number } { return allowanceWindow({ start: subscription.current_period_start, end: subscription.current_period_end }, at) diff --git a/cloud/apps/billing/src/features.ts b/cloud/apps/billing/src/features.ts index 5b85e515088..1448724f718 100644 --- a/cloud/apps/billing/src/features.ts +++ b/cloud/apps/billing/src/features.ts @@ -5,13 +5,19 @@ // live grants in every mode but 'off' (where nothing projects grants, so nothing is on). The // legacy computation has no features, so there is nothing for shadow mode to compare. // -// POST /v1/internal/entitlements/features {accountId} -> { features: { audit_log, sso, scim, mfa_enforcement } } +// POST /v1/internal/entitlements/features {accountId} +// -> { features: { audit_log, sso, scim, mfa_enforcement }, subscribed } +// `subscribed` (hasPaidSubscription) is what auth counts as Orca Cloud access for the account, so a +// paying team is never sent to "Choose a Team plan" because it isn't on the per-account rollout. // Callers: the control plane (CONTROL_PLANE_TOKEN) and auth (MEMBERSHIP_SYNC_TOKEN). Both fail closed. import { z } from 'zod' import { FEATURE_KEYS, type FeatureFlags } from './entitlement-keys' import { entitlementsMode } from './entitlements-mode' import type { Env } from './env' +import { LIVE_STATUSES } from './entitlements' import { grantsForSubject, liveAt } from './grants' +import { currentSubscription } from './store' +import { PAYMENT_FAILED_STATUSES } from './webhook' export const FeaturesRequestSchema = z.object({ accountId: z.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/) }).strict() @@ -35,3 +41,15 @@ export async function featuresFor(env: Pick { + const subscription = await currentSubscription(db, accountId) + return subscription !== null && (LIVE_STATUSES.has(subscription.status) || PAYMENT_FAILED_STATUSES.has(subscription.status)) +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 419c7a6265e..ebc71c6162b 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -15,7 +15,7 @@ // POST /v1/stripe/webhook signed events // Sandbox control plane (CONTROL_PLANE_TOKEN): // POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes} -// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}} plan feature flags (control plane or auth) +// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}, subscribed} plan feature flags and paid-subscription access (control plane or auth) // POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt} // GET /v1/internal/suspensions accounts whose payment grace expired (suspend running) // POST /v1/internal/trial/github {accountId, githubUserId?, githubOwnerId?} GitHub binding -> trial abuse keys @@ -58,7 +58,7 @@ import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './pla import { OverageRequestSchema, updateOverage } from './overage' import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe' import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage' -import { FeaturesRequestSchema, featuresFor } from './features' +import { FeaturesRequestSchema, featuresFor, hasPaidSubscription } from './features' import { handleStripeWebhook } from './webhook' import { UsageHistoryQuerySchema, usageHistory } from './usage-history' import { renderReturnPage, returnOutcome } from './return-page' @@ -526,7 +526,11 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (!body.success) { return json({ error: 'invalid_request' }, 400) } - return json({ features: await featuresFor(env, body.data.accountId, now) }) + const [features, subscribed] = await Promise.all([ + featuresFor(env, body.data.accountId, now), + hasPaidSubscription(env.BILLING_DB, body.data.accountId) + ]) + return json({ features, subscribed }) } case 'POST /v1/internal/usage': { diff --git a/cloud/apps/billing/test/features.test.ts b/cloud/apps/billing/test/features.test.ts index 60e760d6bc2..7fe7cca7c41 100644 --- a/cloud/apps/billing/test/features.test.ts +++ b/cloud/apps/billing/test/features.test.ts @@ -7,6 +7,7 @@ import { featuresFor } from '../src/features' import { grantContactSalesPlan, grantsForSource, revokeManualGrant } from '../src/grants' import { repoCatalog } from './catalog-fixture' import { createHarness } from './harness' +import { checkoutCompleted } from './stripe-fake' const ALL_OFF = { audit_log: false, sso: false, scim: false, mfa_enforcement: false } const ALL_ON = { audit_log: true, sso: true, scim: true, mfa_enforcement: true } @@ -50,11 +51,11 @@ describe('catalog', () => { describe.each(['shadow', 'on'])('features (ENTITLEMENTS_MODE=%s)', (mode) => { it('are off for a team without Enterprise and on for one granted the plan', async () => { const h = await createHarness({ ENTITLEMENTS_MODE: mode }) - expect((await features(h)).body).toEqual({ features: ALL_OFF }) + expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false }) expect(await enterprise(h)).toEqual({ changed: true, planVersion: 2 }) - expect((await features(h)).body).toEqual({ features: ALL_ON }) + expect((await features(h)).body).toEqual({ features: ALL_ON, subscribed: false }) // Another team is unaffected. - expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF }) + expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF, subscribed: false }) }) it('the grant names the real plan and version, and stops when it ends or is revoked', async () => { @@ -76,7 +77,7 @@ describe('features', () => { const h = await createHarness({ ENTITLEMENTS_MODE: 'shadow' }) await enterprise(h) h.env.ENTITLEMENTS_MODE = 'off' - expect((await features(h)).body).toEqual({ features: ALL_OFF }) + expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false }) }) it('only a contact-sales plan can be granted this way', async () => { @@ -121,3 +122,43 @@ describe('features', () => { expect(body.grants.find((grant: { key: string }) => grant.key === 'audit_log')).toMatchObject({ value: true, source: 'manual', plan: { id: 'enterprise', version: 2 }, state: 'active' }) }) }) + +describe('subscribed', () => { + const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * 3600 + const PERIOD_END = PERIOD_START + 30 * 24 * 3600 + + // A paid Team subscription for org:o1, through checkout and Stripe's webhook as in production. + async function paidTeam(mode = 'shadow') { + const h = await createHarness({ ENTITLEMENTS_MODE: mode }) + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: { authorization: `Bearer ${token}` } }) + expect(checkout.status).toBe(200) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ customer, accountId: 'org:o1', basePrice: 'price_team_month', quantity: 2, periodStart: PERIOD_START, periodEnd: PERIOD_END }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + return h + } + const setStatus = (h: Awaited>, status: string) => h.raw.prepare('UPDATE subscriptions SET status = ? WHERE account_id = ?').run(status, 'org:o1') + + it('is true for a team paying for a plan, whatever its hours, and false for everyone else', async () => { + for (const mode of ['off', 'shadow', 'on']) { + const h = await paidTeam(mode) + expect((await features(h)).body).toMatchObject({ subscribed: true }) + // Another team, and the owner's personal account, have no subscription. + expect((await features(h, 'org:o2')).body).toMatchObject({ subscribed: false }) + expect((await features(h, 'user:u1')).body).toMatchObject({ subscribed: false }) + } + }) + + it('stays true while a payment is being retried, and ends with the subscription', async () => { + const h = await paidTeam() + for (const status of ['trialing', 'past_due', 'unpaid']) { + setStatus(h, status) + expect((await features(h)).body, status).toMatchObject({ subscribed: true }) + } + for (const status of ['canceled', 'incomplete', 'incomplete_expired', 'paused']) { + setStatus(h, status) + expect((await features(h)).body, status).toMatchObject({ subscribed: false }) + } + }) +}) diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 8bde55dc75e..e214afa56f6 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -188,12 +188,18 @@ function repoOwner(accountId: string, repo: string): string { return `${accountId}#${repo}` } -/** The owners whose secrets a sandbox session receives. */ -export function ownersForSandbox(row: Pick) { +/** + * The owners whose secrets a sandbox session receives. + * SEC-5: a workspace shared with teammates runs their shells too, so anything in its env is theirs to + * read (and a placeholder is theirs to spend). It gets the team's and the repository's secrets only, + * never its owner's personal ones, the same rule as personal accounts (D13). Fails closed: only a row + * known to be private counts as unshared. + */ +export function ownersForSandbox(row: Pick) { const repo = normalizeRepo(row.repo_url) return { org: ORG_ACCOUNT.test(row.account_id) ? row.account_id : null, - personal: personalOwner(row.user_id, row.account_id.startsWith('dev:')), + personal: row.visibility === 'private' ? personalOwner(row.user_id, row.account_id.startsWith('dev:')) : null, repo: repo ? repoOwner(row.account_id, repo) : null } } @@ -352,16 +358,22 @@ export type ResolvedSecrets = { env: Record; scopes: Record + row: Pick ): Promise { const owners = ownersForSandbox(row) - const pairs: [SecretScope, string][] = [['personal', owners.personal]] + const pairs: [SecretScope, string][] = [] + if (owners.personal) { + pairs.push(['personal', owners.personal]) + } if (owners.org) { pairs.push(['org', owners.org]) } if (owners.repo) { pairs.push(['repo', owners.repo]) } + if (pairs.length === 0) { + return { env: {}, scopes: {}, skipped: 0 } + } const where = pairs.map(() => '(scope = ? AND owner_id = ?)').join(' OR ') const { results } = await db .prepare(`SELECT * FROM secrets WHERE ${where}`) diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index f384583fdf1..c20fbebaa02 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -29,7 +29,7 @@ export type ProviderEnv = { env: Record; scopes: Record + row: Pick ): Promise { const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row) // The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time @@ -45,7 +45,8 @@ const PROVIDER_ENV_CACHE_MS = 60_000 const providerEnvCache = new Map }>() export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise { - const key = `${row.id}:${row.session_id ?? ''}` + // Visibility is part of the key: sharing a workspace stops its owner's personal keys at once (SEC-5). + const key = `${row.id}:${row.session_id ?? ''}:${row.visibility}` const hit = providerEnvCache.get(key) if (hit && now - hit.at < PROVIDER_ENV_CACHE_MS) { return hit.value diff --git a/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts new file mode 100644 index 00000000000..e4e92b532ab --- /dev/null +++ b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts @@ -0,0 +1,216 @@ +// SEC-5: a workspace shared with teammates runs their shells too, so nothing personal to its owner may +// be in its env: not a variable's value, and not a placeholder that would spend the owner's model key. +import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands' +import { ownersForSandbox } from '../src/secrets' +import { createHarness } from './harness' + +const KEK = Buffer.alloc(32, 7).toString('base64') +const PROXY_URL = 'https://sandbox.test/v1/egress/proxy' +// Fake values only. +const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa' +const PERSONAL_OPENAI = 'sk-proj-fake-personal-openai-2222' +const PERSONAL_VALUES = [PERSONAL_OPENAI, 'personal-db-password', 'personal-foo-value', 'personal-aws-secret-value'] + +type Harness = Awaited> + +const logs: string[] = [] +beforeEach(() => { + logs.length = 0 + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + }) + } +}) +afterEach(() => { + for (const value of [...PERSONAL_VALUES, TEAM_KEY]) { + expect(logs.join('\n')).not.toContain(value) + } + vi.restoreAllMocks() +}) + +function decodeEnv(encoded: string | undefined): Record { + return Object.fromEntries( + (encoded ?? '') + .split('\n') + .filter(Boolean) + .map((line) => { + const at = line.indexOf('=') + return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')] + }) + ) +} + +/** The env the VM got at its last boot, or from its last live refresh. */ +function vmEnv(h: Harness, command: string = START_COMMAND): Record { + return decodeEnv(h.vercel.commands.filter((c) => c.command === command).at(-1)!.env.ORCA_SECRET_ENV) +} + +/** Everything the control plane ever sent the VM, as one string: no personal value may be in it. */ +function everythingSent(h: Harness, from = 0): string { + return JSON.stringify( + h.vercel.commands.slice(from).map((c) => ({ ...c, env: { ...c.env, ORCA_SECRET_ENV: decodeEnv(c.env.ORCA_SECRET_ENV) } })) + ) +} + +async function seeded(overrides: Record = {}, deps: Parameters[1] = {}) { + const h = await createHarness({ SECRETS_KEK: KEK, ...overrides }, deps) + const admin = await h.token('ada', 'acme', 'sandbox', { orgRole: 'admin' }) + const mia = await h.token('mia', 'acme', 'sandbox', { orgRole: 'member' }) + const put = (scope: string, name: string, value: string, bearer: string, repo?: string) => + h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer }) + await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin) + await put('org', 'FOO', 'team-foo-value', admin) + await put('repo', 'BAR', 'repo-bar-value', admin, 'acme/app') + await put('personal', 'OPENAI_API_KEY', PERSONAL_OPENAI, mia) + await put('personal', 'DB_PASSWORD', 'personal-db-password', mia) + await put('personal', 'FOO', 'personal-foo-value', mia) + await put('personal', 'AWS_SECRET_ACCESS_KEY', 'personal-aws-secret-value', mia) + const create = (visibility?: 'team' | 'private') => + h.call('POST', '/v1/sandboxes', { + body: { workspaceId: 'ws-1', machineSize: 'standard', repo: { url: 'https://github.com/acme/app' }, ...(visibility ? { visibility } : {}) }, + bearer: mia + }) + const share = (id: string, visibility: 'team' | 'private') => + h.call('PATCH', `/v1/team/workspaces/${id}`, { body: { visibility }, bearer: mia }) + return { h, mia, admin, put, create, share } +} + +const TEAM_AND_REPO = { ANTHROPIC_API_KEY: TEAM_KEY, BAR: 'repo-bar-value', FOO: 'team-foo-value' } + +describe('personal secrets in shared workspaces (SEC-5)', () => { + it('resolves no personal owner for a shared row, and fails closed on an unknown visibility', () => { + const row = { account_id: 'org:acme', user_id: 'mia', repo_url: 'https://github.com/acme/app' } + expect(ownersForSandbox({ ...row, visibility: 'private' }).personal).toBe('user:mia') + expect(ownersForSandbox({ ...row, visibility: 'team' })).toEqual({ org: 'org:acme', personal: null, repo: 'org:acme#github.com/acme/app' }) + expect(ownersForSandbox({ ...row, visibility: undefined as never }).personal).toBeNull() + }) + + it('keeps the owner’s personal secrets in their own private workspace', async () => { + const { h, create } = await seeded() + expect((await create()).status).toBe(200) + expect(vmEnv(h)).toEqual({ + ...TEAM_AND_REPO, + AWS_SECRET_ACCESS_KEY: 'personal-aws-secret-value', + DB_PASSWORD: 'personal-db-password', + FOO: 'personal-foo-value', + OPENAI_API_KEY: PERSONAL_OPENAI + }) + }) + + it('starts a shared workspace with team and repository secrets only; the team value replaces a personal override', async () => { + const { h, create } = await seeded() + expect((await create('team')).status).toBe(200) + expect(vmEnv(h)).toEqual(TEAM_AND_REPO) + for (const value of PERSONAL_VALUES) { + expect(everythingSent(h)).not.toContain(value) + } + }) + + it('drops them from a running workspace the moment it is shared, and brings them back when it is private again', async () => { + const { h, create, share, mia } = await seeded() + const id: string = (await create()).body.sandbox.id + expect(vmEnv(h).DB_PASSWORD).toBe('personal-db-password') + const before = h.vercel.commands.length + + expect((await share(id, 'team')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).toEqual(TEAM_AND_REPO) + for (const value of PERSONAL_VALUES) { + expect(everythingSent(h, before)).not.toContain(value) + } + // A wake while shared resolves the same way. + await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia }) + await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia }) + expect(vmEnv(h)).toEqual(TEAM_AND_REPO) + + expect((await share(id, 'private')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).toMatchObject({ DB_PASSWORD: 'personal-db-password', FOO: 'personal-foo-value' }) + }) + + it('does not pick up a personal secret the owner saves while the workspace is shared', async () => { + const { h, create, put, mia } = await seeded() + await create('team') + await put('personal', 'LATER', 'personal-db-password', mia) + const refreshes = h.vercel.commands.filter((c) => c.command === ENV_COMMAND) + for (const refresh of refreshes) { + expect(decodeEnv(refresh.env.ORCA_SECRET_ENV)).toEqual(TEAM_AND_REPO) + } + }) +}) + +describe('personal model keys in shared workspaces with egress placeholders (SEC-5)', () => { + async function placeholderSetup() { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] } + const upstream: { url: string; headers: Headers }[] = [] + const ctx = await seeded( + { SECRETS_EGRESS_PLACEHOLDERS: 'on', EGRESS_PROXY_URL: PROXY_URL }, + { + egressKeysForIssuer: () => createLocalJWKSet(jwks), + credentials: { authorizationFor: async () => 'Bearer ghs_fake' }, + egressFetch: async (input, init) => { + upstream.push({ url: String(input), headers: new Headers(init?.headers) }) + return new Response('ok', { status: 200 }) + } + } + ) + async function proxied(sandboxId: string, host: string, headers: Record) { + const token = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` }) + .setProtectedHeader({ alg: 'RS256', kid: 'v1' }) + .setIssuer('https://oidc.vercel.com/team_orca') + .setAudience(PROXY_URL) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey) + const response = await ctx.h.send( + new Request(`${PROXY_URL}/v1/messages`, { + method: 'POST', + body: '{"model":"x"}', + headers: { + 'vercel-forwarded-host': host, + 'vercel-forwarded-scheme': 'https', + 'vercel-forwarded-port': '443', + 'vercel-forwarded-path': '/v1/messages', + 'vercel-sandbox-oidc-token': token, + ...headers + } + }) + ) + return response.status + } + return { ...ctx, proxied, upstream } + } + + it('gives a shared workspace no placeholder and no route for the owner’s personal key; the team key still works', async () => { + const { h, create, proxied, upstream } = await placeholderSetup() + const id: string = (await create('team')).body.sandbox.id + const env = vmEnv(h) + expect(env.ANTHROPIC_API_KEY).toBe(`orca-ph-anthropic-api-key-${id}`) + expect(env).not.toHaveProperty('OPENAI_API_KEY') + expect(env).not.toHaveProperty('DB_PASSWORD') + const [box] = [...h.vercel.boxes.values()] + // No forward rule for the personal key: the host is plain allowlisted, nothing is swapped there. + expect(box!.policy.allow['api.openai.com']).toEqual([]) + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer orca-ph-openai-api-key-${id}` })).toBe(403) + expect(await proxied(id, 'api.anthropic.com', { 'x-api-key': `orca-ph-anthropic-api-key-${id}` })).toBe(200) + expect(upstream).toHaveLength(1) + expect(upstream[0]!.headers.get('x-api-key')).toBe(TEAM_KEY) + }) + + it('stops swapping the owner’s personal key at the proxy as soon as a running workspace is shared', async () => { + const { h, create, share, proxied, upstream } = await placeholderSetup() + const id: string = (await create()).body.sandbox.id + const placeholder = `orca-ph-openai-api-key-${id}` + expect(vmEnv(h).OPENAI_API_KEY).toBe(placeholder) + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(200) + expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${PERSONAL_OPENAI}`) + + expect((await share(id, 'team')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).not.toHaveProperty('OPENAI_API_KEY') + // A process still holding the old placeholder gets nothing for it. + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(403) + expect(upstream).toHaveLength(1) + }) +}) diff --git a/src/main/hosted-sandbox/cloud-create-from-github.ts b/src/main/hosted-sandbox/cloud-create-from-github.ts index 1809caa686c..23dca09b989 100644 --- a/src/main/hosted-sandbox/cloud-create-from-github.ts +++ b/src/main/hosted-sandbox/cloud-create-from-github.ts @@ -73,11 +73,6 @@ export async function createCloudWorkspaceFromGitHub( onProgress: (progress) => io.onLog?.(hostedProgressLine(progress)) } ) - if (io.signal?.aborted) { - // Cancelled just as it answered: nothing will open it, so it must not keep running (and billing). - await client.destroy(ready.sandbox.id).catch(() => undefined) - return { ok: false, code: 'failed', message: 'Cancelled.' } - } return { ok: true, value: { sandboxId: ready.sandbox.id } } } catch (error) { const result = hostedCreateFailure(error) diff --git a/src/main/hosted-sandbox/github-repo-ids.test.ts b/src/main/hosted-sandbox/github-repo-ids.test.ts new file mode 100644 index 00000000000..c3ba48f95e2 --- /dev/null +++ b/src/main/hosted-sandbox/github-repo-ids.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { parseGitHubInstallRights } from './github-repo-ids' + +it("reads gh's admin flag and owner type, and nothing else", () => { + expect(parseGitHubInstallRights('true\nOrganization\n')).toEqual({ + admin: true, + ownerType: 'Organization' + }) + expect(parseGitHubInstallRights('false\nUser')).toEqual({ admin: false, ownerType: 'User' }) + // No permissions block (gh not signed in to this host) or an unknown owner type: can't tell. + expect(parseGitHubInstallRights('null\nUser')).toBeNull() + expect(parseGitHubInstallRights('true\nEnterprise')).toBeNull() + expect(parseGitHubInstallRights('')).toBeNull() +}) diff --git a/src/main/hosted-sandbox/github-repo-ids.ts b/src/main/hosted-sandbox/github-repo-ids.ts index dc162ba904f..93b5623dc80 100644 --- a/src/main/hosted-sandbox/github-repo-ids.ts +++ b/src/main/hosted-sandbox/github-repo-ids.ts @@ -3,6 +3,7 @@ // private repository's id isn't readable anonymously, and the control plane never sees a user token. // Best effort: no gh, not signed in, or slow means the plain install page instead. import { ghExecFileAsync } from '../git/command-runner/hosted-cli/gh-exec-file' +import type { GitHubInstallRights } from '../../shared/hosted-sandbox/cloud-workspace-api' export type GitHubRepoIds = { repoId: number; ownerId: number } @@ -60,3 +61,54 @@ export function lookupGitHubRepoIds(repoUrl: string): Promise }>() + +/** `.permissions.admin, .owner.type` from `gh api repos/`; null when gh couldn't tell. */ +export function parseGitHubInstallRights(stdout: string): GitHubInstallRights | null { + const [admin, ownerType] = stdout.trim().split(/\s+/) + if (admin !== 'true' && admin !== 'false') { + return null + } + return ownerType === 'User' || ownerType === 'Organization' + ? { admin: admin === 'true', ownerType } + : null +} + +/** + * Can the person install the Orca GitHub App on this repository's owner? GitHub lets an owner of a + * personal account, an org owner, or a repository admin install it; anyone else (octocat's public + * Hello-World, say) can't, and an org member's attempt becomes a request for an owner. + */ +export function lookupGitHubInstallRights( + repoUrl: string, + now = Date.now() +): Promise { + const slug = githubRepoSlug(repoUrl) + if (!slug) { + return Promise.resolve(null) + } + const key = slug.toLowerCase() + const cached = rightsCache.get(key) + if (cached && now - cached.at < RIGHTS_CACHE_MS) { + return cached.value + } + const value = Promise.race([ + ghExecFileAsync(['api', `repos/${slug}`, '--jq', '.permissions.admin, .owner.type'], { + host: 'github.com', + idempotent: true + }).then(({ stdout }) => parseGitHubInstallRights(stdout)), + new Promise((resolve) => setTimeout(() => resolve(null), LOOKUP_TIMEOUT_MS)) + ]).catch(() => null) + const entry = { at: now, value } + // Misses aren't kept: a later gh sign-in is picked up on the next ask. + void value.then((rights) => { + if (!rights && rightsCache.get(key) === entry) { + rightsCache.delete(key) + } + }) + rightsCache.set(key, entry) + return value +} diff --git a/src/main/hosted-sandbox/hosted-account-ipc.test.ts b/src/main/hosted-sandbox/hosted-account-ipc.test.ts index f0249045c51..7f55913a52d 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.test.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.test.ts @@ -219,3 +219,30 @@ it('reads the config once, and again after a failed read', async () => { await handlers.get(CH.getConfig)?.() expect(config).toHaveBeenCalledTimes(2) }) + +it("reads the person's install rights with their gh login, for GitHub repositories only", async () => { + const installRights = vi.fn(async () => ({ admin: false, ownerType: 'User' as const })) + registerHostedAccountHandlers( + { + config: async () => ({ idleSleepMinutes: 15 }), + connectGitHub: async () => ({ installUrl: REPO, authorizeUrl: REPO }), + githubRepoAccess: async () => ({ access: 'public', owner: 'octocat', repo: REPO }), + githubRepositories: async () => ({ repositories: [], nextCursor: null }), + create: unusedCreate, + ...githubUser + }, + { repoIds: async () => null, installRights } + ) + const rights = handlers.get(CH.gitHubInstallRights) + expect(await rights?.({}, { repoUrl: 'https://github.com/octocat/Hello-World.git' })).toEqual({ + ok: true, + value: { admin: false, ownerType: 'User' } + }) + expect(installRights).toHaveBeenCalledWith('https://github.com/octocat/Hello-World') + expect(await rights?.({}, { repoUrl: 'https://gitlab.com/acme/web' })).toMatchObject({ + ok: false + }) + // gh failing reads as "can't tell", never as an error the notice has to handle. + installRights.mockRejectedValueOnce(new Error('gh: not logged in')) + expect(await rights?.({}, { repoUrl: REPO })).toEqual({ ok: true, value: null }) +}) diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts index 30b7e1294db..0811c502970 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.ts @@ -5,7 +5,13 @@ import { ORCA_CLOUD_WORKSPACE_CHANNELS as CH } from '../../shared/hosted-sandbox import type { HostedSandboxConfigResponse } from '../../shared/hosted-sandbox/hosted-sandbox-wire' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { failure } from './hosted-workspace-runtime' -import { githubRepoSlug, lookupGitHubRepoIds, type GitHubRepoIds } from './github-repo-ids' +import { + githubRepoSlug, + lookupGitHubInstallRights, + lookupGitHubRepoIds, + type GitHubRepoIds +} from './github-repo-ids' +import type { GitHubInstallRights } from '../../shared/hosted-sandbox/cloud-workspace-api' import { CLOUD_CREATE_IDEMPOTENCY_KEY, CLOUD_CREATE_PROVISION_ID, @@ -24,6 +30,7 @@ export const HOSTED_ACCOUNT_CHANNELS = [ CH.getConfig, CH.connectGitHub, CH.getRepoAccess, + CH.gitHubInstallRights, CH.listGitHubRepositories, CH.createFromGitHub, ...HOSTED_GITHUB_USER_CHANNELS @@ -59,9 +66,13 @@ export function registerHostedAccountHandlers( | 'connectGitHubUser' | 'disconnectGitHubUser' >, - options: { repoIds?: (repoUrl: string) => Promise } = {} + options: { + repoIds?: (repoUrl: string) => Promise + installRights?: (repoUrl: string) => Promise + } = {} ): void { const repoIds = options.repoIds ?? lookupGitHubRepoIds + const installRights = options.installRights ?? lookupGitHubInstallRights registerHostedGitHubUserHandlers(client) // The Worker re-measures every 5 minutes; match it so each new build sheet sees a fresh median. let config: { at: number; value: Promise } | null = null @@ -128,6 +139,15 @@ export function registerHostedAccountHandlers( } }) + // Read with the person's own gh login; the control plane never sees a user token. + ipcMain.handle(CH.gitHubInstallRights, async (_event, args?: RepoArgs) => { + const target = repoTarget(args) + if (!target) { + return { ok: false, code: 'failed', message: 'Not a GitHub repository.' } + } + return { ok: true, value: await installRights(target.repo).catch(() => null) } + }) + ipcMain.handle(CH.listGitHubRepositories, async (_event, args?: { cursor?: unknown }) => { try { const cursor = typeof args?.cursor === 'string' ? args.cursor : null diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts new file mode 100644 index 00000000000..b5686f8a2ee --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts @@ -0,0 +1,105 @@ +// GH-CREATE-CANCEL: a create cancelled in flight never leaves a sandbox running, for the owned +// create and the GitHub-only create alike (both use createSandboxWithProgress). +import { describe, expect, it, vi } from 'vitest' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { HostedEntitlementDeniedError } from './hosted-environment-provider' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' +import { createSandboxWithProgress } from './hosted-sandbox-driver' + +const READY = { + sandbox: { + id: 'sbx_1', + workspaceId: 'web-1', + status: 'running' as const, + machineSize: 'standard' as const, + region: 'iad1', + session: null, + createdAt: 1 + }, + connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' } +} +const BODY = { workspaceId: 'web-1', machineSize: 'standard' as const } +const quiet = { waitMs: 0, log: () => {} } + +function client() { + return { + create: vi.fn(async () => READY), + bootPlan: vi.fn(async () => 'clone' as const), + cloneStatus: vi.fn(async () => ({ state: 'done' as const })), + destroy: vi.fn(async (id: string) => ({ id, status: 'destroyed' as const })) + } +} + +describe('reclaimCancelledCreate', () => { + it('replays the create by its key and deletes the sandbox it gets back', async () => { + const api = client() + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledWith(BODY, undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('waits while the first create is still in progress (409), then deletes', async () => { + const api = client() + api.create + .mockRejectedValueOnce(new EnvironmentProviderError('conflict', 'idempotency_in_progress')) + .mockRejectedValueOnce(new EnvironmentProviderError('unavailable', 'try later')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledTimes(3) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('a refused replay means the cancelled create made nothing either', async () => { + const api = client() + api.create.mockRejectedValueOnce(new HostedEntitlementDeniedError('denied', 'no_subscription')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.destroy).not.toHaveBeenCalled() + }) + + it('gives up (false) when it can never tell', async () => { + const api = client() + api.create.mockRejectedValue(new EnvironmentProviderError('unavailable', 'down')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', { ...quiet, attempts: 3 })).toBe( + false + ) + }) +}) + +describe('createSandboxWithProgress cancel', () => { + const spec = { workspaceId: 'web-1', machineSize: 'standard', idempotencyKey: 'create:k1' } + + it('cancelled while the create was in flight: reclaims by key, then reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + throw new EnvironmentProviderError('aborted', 'Cancelled.') + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + // The replay (no signal, same key) found the sandbox and deleted it. + expect(api.create).toHaveBeenLastCalledWith(expect.anything(), undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled just as the create answered: deletes the sandbox and reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + return READY + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + expect(api.create).toHaveBeenCalledTimes(1) + }) + + it('not cancelled: no replay, no delete', async () => { + const api = client() + await expect(createSandboxWithProgress(api, spec, {})).resolves.toBe(READY) + expect(api.create).toHaveBeenCalledTimes(1) + expect(api.destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts new file mode 100644 index 00000000000..343360ff400 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts @@ -0,0 +1,64 @@ +// A create cancelled while its request was in flight may still have made a sandbox on the control +// plane, and a sandbox bills (GH-CREATE-CANCEL). The control plane keys creates by Idempotency-Key +// and body: sending the same create again hands back the sandbox the first one made (or makes one, if +// the first never arrived), and that sandbox is deleted. Shared by the owned create (the recipe) and +// the GitHub-only create, which both go through createSandboxWithProgress. +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' + +type CreateBody = Parameters[0] + +const RECLAIM_ATTEMPTS = 8 +const RECLAIM_WAIT_MS = 5_000 + +/** The first create may still be running (409 in progress) or the service briefly unreachable. */ +function worthRetrying(error: unknown): boolean { + return ( + isEnvironmentProviderError(error, 'conflict') || + isEnvironmentProviderError(error, 'unavailable') || + isEnvironmentProviderError(error, 'timeout') || + !isEnvironmentProviderError(error) + ) +} + +/** + * Deletes whatever the cancelled create made. True once it is gone (or the control plane refused + * the create, so nothing was made); false when it still couldn't tell after every attempt. + */ +export async function reclaimCancelledCreate( + client: Pick, + body: CreateBody, + idempotencyKey: string, + { + attempts = RECLAIM_ATTEMPTS, + waitMs = RECLAIM_WAIT_MS, + log = (message: string) => console.warn(message) + }: { attempts?: number; waitMs?: number; log?: (message: string) => void } = {} +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let sandboxId: string + try { + // No signal: this request is the cleanup and must not be cancelled with the create. + sandboxId = (await client.create(body, undefined, idempotencyKey)).sandbox.id + } catch (error) { + if (!worthRetrying(error)) { + // Refused (billing, sign-in, bad request): the cancelled create made nothing either. + return true + } + log( + `[orca-cloud] cancelled create: replay ${attempt}/${attempts} failed (${isEnvironmentProviderError(error) ? error.code : 'error'})` + ) + await new Promise((resolve) => setTimeout(resolve, waitMs)) + continue + } + try { + await client.destroy(sandboxId) + log(`[orca-cloud] cancelled create: deleted sandbox ${sandboxId}`) + return true + } catch { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + } + log('[orca-cloud] cancelled create: could not confirm the sandbox was deleted') + return false +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-driver.ts b/src/main/hosted-sandbox/hosted-sandbox-driver.ts index cb0ccc76f5c..088f92f5b89 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-driver.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-driver.ts @@ -25,6 +25,7 @@ import type { } from './hosted-environment-provider' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { takeSuspendReason } from './idle-sleep-requests' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' export const HOSTED_SANDBOX_DRIVER_ID = 'orca-cloud' @@ -144,21 +145,34 @@ export async function createSandboxWithProgress( ) .catch(() => undefined) } - const ready = await client.create( - { - workspaceId: spec.workspaceId, - machineSize, - // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). - ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), - ...(spec.region ? { region: spec.region } : {}), - ...(spec.repo ? { repo: spec.repo } : {}) - }, - ctx?.signal, - spec.idempotencyKey - ) + const body = { + workspaceId: spec.workspaceId, + machineSize, + // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). + ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), + ...(spec.region ? { region: spec.region } : {}), + ...(spec.repo ? { repo: spec.repo } : {}) + } + let ready: HostedSandboxReadyResponse + try { + ready = await client.create(body, ctx?.signal, spec.idempotencyKey) + } catch (error) { + // GH-CREATE-CANCEL: cancelled in flight, the control plane may still have made it: find it by + // its key and delete it before reporting the cancel. + if (ctx?.signal?.aborted && spec.idempotencyKey) { + await reclaimCancelledCreate(client, body, spec.idempotencyKey) + } + throw error + } if (ready.clone) { + // A cancelled, failed or stalled clone deletes the sandbox itself. await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' }) } + if (ctx?.signal?.aborted) { + // Cancelled just as it answered: nothing will use it, so it must not keep running (and billing). + await client.destroy(ready.sandbox.id).catch(() => undefined) + throw new EnvironmentProviderError('aborted', 'Cancelled.') + } ctx?.onProgress?.({ phase: 'connecting' }) return ready } diff --git a/src/preload/api/orca-cloud-workspace-bridge.ts b/src/preload/api/orca-cloud-workspace-bridge.ts index 86194e59d79..a86f6382db4 100644 --- a/src/preload/api/orca-cloud-workspace-bridge.ts +++ b/src/preload/api/orca-cloud-workspace-bridge.ts @@ -25,6 +25,7 @@ export const orcaCloudWorkspaceApi = { gitHubUserConnectLink: () => ipcRenderer.invoke(CH.gitHubUserConnectLink), disconnectGitHubUser: () => ipcRenderer.invoke(CH.disconnectGitHubUser), getRepoAccess: (args) => ipcRenderer.invoke(CH.getRepoAccess, args), + gitHubInstallRights: (args) => ipcRenderer.invoke(CH.gitHubInstallRights, args), listGitHubRepositories: (args) => ipcRenderer.invoke(CH.listGitHubRepositories, args), createFromGitHub: (args) => ipcRenderer.invoke(CH.createFromGitHub, args), onWakeProgress: (callback) => { diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx index 3132c871845..5583bcb9e7c 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { CloudStripPasteForm } from './CloudStripPasteForm' import { StripPrimaryButton, stripPrimaryLink } from './CloudStripPrimaryButton' +import { CloudStripSettingsLink } from './CloudStripSettingsLink' import { DropdownMenu, DropdownMenuContent, @@ -103,6 +104,8 @@ export type CloudStripActions = { * setup's sign-in page, known only to main). Actions carrying a `url` copy it without this. */ linkFor?: (action: CloudStripPrimary) => ExternalLinkSource | null + /** CHAT-RENEW-INPLACE: the model's `settingsLink` ("Renew in Settings"). */ + openSettings?: () => void } /** GA-P3t: the strip's severity look; anything that isn't a warning or an error reads as info. */ @@ -336,6 +339,9 @@ export function CloudAgentSignInStrip({ ) : null} + {model.settingsLink && !stacked ? ( + + ) : null} {/* Renew and Restart are the whole action and the strip leaves on its own; Escape still cancels. */} {model.noClose || (!model.dismiss && @@ -378,6 +384,13 @@ export function CloudAgentSignInStrip({ {model.primary.label} ) : null} + {model.settingsLink ? ( + + ) : null} ) : null} diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx new file mode 100644 index 00000000000..387efbaaf71 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx @@ -0,0 +1,136 @@ +// CHAT-RENEW-INPLACE: the in-place renewal, step by step, on all three surfaces (a launch held on the +// expired sign-in, a running terminal, a structured chat). design-squad's per-step copy. +import React from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { translate } from '@/i18n/i18n' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + CloudAgentSignInStrip, + CloudStripLayer, + cloudStripWidth, + type CloudStripActions +} from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { cloudAgentStripModel } from './cloud-agent-strip-model' +import { cloudAgentAwaitingStartStripModel } from './cloud-agent-setup-strip-model' +import { cloudChatRunningStripModel, cloudRunningStripModel } from './cloud-running-strip-model' +import { cloudRenewInPlaceModel } from './cloud-renew-in-place' + +const noop = (): void => {} +const ACTIONS: CloudStripActions = { + primary: noop, + submitCode: noop, + pasteCode: noop, + useCode: noop, + useApiKey: noop, + cancel: noop, + openSettings: noop +} + +type Surface = 'held' | 'terminal' | 'chat' +type Step = 'expired' | 'browser' | 'checking' | 'rejected' | 'renewed' + +const SETUP: Record, OrcaCloudAgentSetup> = { + expired: { phase: 'idle' }, + browser: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true }, + checking: { + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'checking' + }, + rejected: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true, code: 'rejected' } +} + +function expiredModel(surface: Surface): CloudStripModel { + const strip = { phase: 'expired' as const, agent: 'claude' as const } + return surface === 'held' + ? cloudAgentStripModel({ + agent: 'claude', + reason: 'accountExpired', + view: null, + hostLabel: '', + t: translate + }) + : surface === 'chat' + ? cloudChatRunningStripModel(strip, translate) + : cloudRunningStripModel(strip, translate) +} + +function stepModel(surface: Surface, step: Step): CloudStripModel { + if (step !== 'renewed') { + return cloudRenewInPlaceModel({ + agent: 'claude', + expired: expiredModel(surface), + setup: SETUP[step], + t: translate + }) + } + const renewed = { phase: 'renewed' as const, agent: 'claude' as const, resumable: true } + // Step 5 is each surface's own: held waits for Start (team-lead B), the terminal offers Restart + // (RA-11), the chat's next message uses the renewed sign-in. + return surface === 'held' + ? cloudAgentAwaitingStartStripModel({ + agent: 'claude', + awaiting: 'renewed', + queuedPrompt: true, + t: translate + }) + : surface === 'chat' + ? cloudChatRunningStripModel(renewed, translate) + : cloudRunningStripModel(renewed, translate) +} + +function Frame({ + surface, + step, + width +}: { + surface: Surface + step: Step + width: number +}): React.JSX.Element { + return ( +
+
+ + + +
+
+ ) +} + +const fixture = (surface: Surface, step: Step, width = 900) => + defineFixture({ + width: width + 40, + render: () => + }) + +export const heldExpired = fixture('held', 'expired') +export const heldBrowser = fixture('held', 'browser') +export const heldChecking = fixture('held', 'checking') +export const heldRejected = fixture('held', 'rejected') +export const heldRenewed = fixture('held', 'renewed') +export const terminalExpired = fixture('terminal', 'expired') +export const terminalBrowser = fixture('terminal', 'browser') +export const terminalChecking = fixture('terminal', 'checking') +export const terminalRejected = fixture('terminal', 'rejected') +export const terminalRenewed = fixture('terminal', 'renewed') +export const chatExpired = fixture('chat', 'expired') +export const chatBrowser = fixture('chat', 'browser') +export const chatChecking = fixture('chat', 'checking') +export const chatRejected = fixture('chat', 'rejected') +export const chatRenewed = fixture('chat', 'renewed') +/** Narrow: the code field stacks under the text, and "Renew in Settings" gets its own line. */ +export const chatBrowserNarrow = fixture('chat', 'browser', 440) +export const terminalRejectedNarrow = fixture('terminal', 'rejected', 440) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx index 33e5548aa71..33af6feba9f 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.test.tsx @@ -49,7 +49,14 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({ })) vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ orcaCloudAgentAccountsLoadedAt: () => 0, - useOrcaCloudAgentAccounts: () => ({ load: { kind: 'loading' }, keyKinds: {} }) + useOrcaCloudAgentAccounts: () => ({ + load: { kind: 'loading' }, + keyKinds: {}, + setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } } + }), + runOrcaCloudAgentSetups: () => undefined, + stopOrcaCloudAgentSetups: () => undefined, + reloadOrcaCloudAgentAccounts: async () => undefined })) vi.mock('@/lib/orca-cloud/cloud-running-agent-signout', () => ({ clearCloudRunningSignout: () => undefined, diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx index bdfde2a50c4..690d5b6aa60 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx @@ -42,6 +42,7 @@ import { cloudRestartFreshModel, cloudRunningStripModel } from './cloud-running-strip-model' +import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place' const NO_ACTIONS: CloudStripActions = { primary: () => undefined, @@ -52,12 +53,6 @@ const NO_ACTIONS: CloudStripActions = { cancel: () => undefined } -function openAccounts(): void { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) - store.openSettingsPage() -} - export function CloudRunningStripPortal({ pane, tabId, @@ -192,7 +187,7 @@ export function CloudRunningStripPortal({ const actions: CloudStripActions = { primary: (action) => { if (action.kind === 'renew') { - openAccounts() + openOrcaCloudAccountsSettings() } else if (action.kind === 'restart' && strip.phase === 'renewed') { restart({ agent: strip.agent, resumable: strip.resumable }) } @@ -204,6 +199,19 @@ export function CloudRunningStripPortal({ // State 2's ×; state 1 has none (Renew is the action) and Escape leaves it showing. cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(paneKey) : undefined) } + // CHAT-RENEW-INPLACE: "sign-in expired · Renew" renews right here; renewed keeps RA-11's Restart. + const model = cloudRunningStripModel(strip, t) + const shown = + strip.phase === 'expired' + ? withRenewInPlace({ + agent: strip.agent, + setup: accounts.setups[strip.agent], + model, + actions, + key: paneKey, + t + }) + : { model, actions } return ( ) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx new file mode 100644 index 00000000000..39bce76b0dc --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { cn } from '@/lib/class-names' + +/** + * CHAT-RENEW-INPLACE: the strip's quiet "Renew in Settings" link, at the end of the actions row, or on + * its own line under the buttons in the narrow stack (design-squad). + */ +export function CloudStripSettingsLink({ + label, + onOpen, + ownLine = false +}: { + label: string + onOpen?: () => void + ownLine?: boolean +}): React.JSX.Element | null { + if (!onOpen) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx index ba0b56c6ba1..4e8bf438777 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudStructuredChatBanners.tsx @@ -22,16 +22,19 @@ function tabStillOpen(tabId: string): boolean { export function CloudStructuredChatBanners({ tabId, paneKey, - statusKey + statusKey, + agent }: { tabId: string /** The launch-hold key (from the session id). */ paneKey: string /** The agent-status key (tab and session): the running strip (B2) reads the agent through it. */ statusKey: string + /** The chat's agent, for an idle chat with no status yet. */ + agent?: string }): React.JSX.Element { const { key, active, strip } = useCloudHeldStrip(paneKey, null, { chat: true }) - const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey }) + const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey, agent }) const shown = strip ?? running useEffect(() => { if (!active) { diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts index 547f12c3306..388752f0dd0 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.test.ts @@ -5,11 +5,11 @@ import { cloudAgentStripModel } from './cloud-agent-strip-model' const base = { agent: 'claude' as const, hostLabel: 'the sandbox', t: translate } describe('cloudAgentStripModel', () => { - it('account recovery (S4-c): Renew, account-level wording, no per-workspace words', () => { + it('account recovery (S4-c), held: Renew, "Renew to start Claude." (CHAT-RENEW-INPLACE copy)', () => { const model = cloudAgentStripModel({ ...base, reason: 'accountExpired', view: null }) expect(model).toMatchObject({ title: 'Your Claude sign-in expired', - detail: 'Sign in once to fix every workspace.', + detail: 'Renew to start Claude.', primary: { label: 'Renew', action: { kind: 'renew' } } }) }) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts index 3f7b7a6baa6..e0f7827e35e 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-agent-strip-model.ts @@ -57,6 +57,11 @@ export type CloudStripModel = { otherWays: boolean /** Already on a code: ⋯ offers the paste fallback and Cancel instead of "Use a code". */ codeStep?: boolean + /** + * CHAT-RENEW-INPLACE: a quiet text link at the end of the actions row ("Renew in Settings"); in + * the narrow stack it gets its own line under the buttons. + */ + settingsLink?: string /** RA-11 state 2: a dismiss × with this label (the strip otherwise drops its × on Renew). */ dismiss?: string /** RA-11 "Restarting…": nothing to close; the strip leaves with the result. */ @@ -121,15 +126,15 @@ export function cloudAgentStripModel(args: { return { status: 'warning', title: t('agentSignInStrip.expired', 'Your {{agent}} sign-in expired', { agent }), - detail: t('agentSignInStrip.expiredDetail', 'Sign in once to fix every workspace.'), + detail: t('agentSignInStrip.expiredHeldDetail', 'Renew to start {{agent}}.', { agent }), description: null, primary: { label: t('agentSignInStrip.renew', 'Renew'), action: { kind: 'renew' } }, code: null, paste: null, otherWays: false, announcement: t( - 'agentSignInStrip.expiredAnnounce', - 'Your {{agent}} sign-in expired. Renew it once to fix every workspace.', + 'agentSignInStrip.expiredHeldAnnounce', + 'Your {{agent}} sign-in expired. Renew to start {{agent}}.', { agent } ) } diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx index a53c70fc85f..aac72b6f24e 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx @@ -2,7 +2,7 @@ // CHAT-BANNERS B2: RA-11's running strip on a structured chat. Expired shows Renew; renewed confirms // with no Restart (the chat's next message uses the renewed sign-in) and its × dismisses; a held // launch's strip wins over it. -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' @@ -21,7 +21,8 @@ const stub = vi.hoisted(() => { cleared: string[] calls: { paneKey: string; turnEndedFallback?: boolean }[] openedSettings: number - } = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0 } + renewsRun: string[][] + } = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0, renewsRun: [] } return state }) @@ -47,6 +48,9 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({ })) vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ orcaCloudAgentAccountsLoadedAt: () => 1, + runOrcaCloudAgentSetups: (agents: string[]) => stub.renewsRun.push(agents), + stopOrcaCloudAgentSetups: () => undefined, + reloadOrcaCloudAgentAccounts: async () => undefined, useOrcaCloudAgentAccounts: () => ({ load: { kind: 'loading' }, keyKinds: {}, @@ -94,6 +98,7 @@ vi.stubGlobal( ) const { CloudStructuredChatBanners } = await import('./CloudStructuredChatBanners') +const { useCloudChatSendBlock } = await import('./use-cloud-chat-running-strip') const mount = () => render( @@ -117,7 +122,9 @@ describe('the running strip on a structured chat (B2)', () => { mount() expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: /Renew/ })) - expect(stub.openedSettings).toBe(1) + // CHAT-RENEW-INPLACE: Renew runs the renewal right here, never Settings. + expect(stub.renewsRun).toEqual([['claude']]) + expect(stub.openedSettings).toBe(0) expect(stub.calls.at(-1)).toEqual({ paneKey: STATUS_KEY, turnEndedFallback: true }) }) @@ -139,3 +146,54 @@ describe('the running strip on a structured chat (B2)', () => { expect(screen.queryByRole('button', { name: /Renew/ })).toBeNull() }) }) + +describe('an idle chat (no message yet, no status entry)', () => { + const IDLE_TAB = 'structured-agent-session-claude_idle' + const idle = () => + render( + + + + ) + + it("expired account: the strip shows, decided from the chat's own agent", () => { + stub.strip = { phase: 'expired', agent: 'claude' } + idle() + expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0) + expect(stub.calls.at(-1)?.paneKey).toBe(`${IDLE_TAB}:no-status`) + }) + + it('covered account: no strip', () => { + idle() + expect(screen.queryByRole('region')).toBeNull() + }) + + it('a held launch shows the held strip instead', () => { + stub.strip = { phase: 'expired', agent: 'claude' } + stub.held = true + idle() + expect(screen.getByRole('button', { name: 'Set up once' })).toBeTruthy() + }) +}) + +describe('Send while the account is expired', () => { + const args = { tabId: TAB_ID, statusKey: STATUS_KEY, holdKey: HOLD_KEY, agent: 'claude' } + + it('expired: Send is held with the Renew hint (typing is not touched)', () => { + stub.strip = { phase: 'expired', agent: 'claude' } + const { result } = renderHook(() => useCloudChatSendBlock(args)) + expect(result.current).toBe('Renew Claude sign-in above to send') + }) + + it('renewed or covered: Send is free again', () => { + stub.strip = { phase: 'renewed', agent: 'claude', resumable: true } + expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined() + stub.strip = null + expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts new file mode 100644 index 00000000000..8620ee7337e --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts @@ -0,0 +1,153 @@ +// CHAT-RENEW-INPLACE: every expired strip renews in place, with design-squad's per-step copy. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import type { CloudStripActions } from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { translate as t } from '@/i18n/i18n' + +const store = vi.hoisted(() => ({ run: vi.fn(), stop: vi.fn() })) +vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ + runOrcaCloudAgentSetups: store.run, + stopOrcaCloudAgentSetups: store.stop, + reloadOrcaCloudAgentAccounts: async () => undefined +})) +vi.mock('@/lib/orca-cloud/cloud-agent-launch-hold', () => ({ + releaseCloudAgentHold: vi.fn(), + startCloudAgentHold: vi.fn() +})) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('../agent-sign-in/AgentSignInPanePortals', () => ({ + agentSignInCardActions: () => ({ copyText: vi.fn(), openLink: vi.fn() }) +})) +const api = vi.hoisted(() => ({ reopen: vi.fn(async () => undefined), submitClaudeCode: vi.fn() })) +vi.stubGlobal('window', { api: { orcaCloudAgents: api } }) + +const { cloudRenewInPlaceActions, cloudRenewInPlaceModel } = await import('./cloud-renew-in-place') + +const EXPIRED: CloudStripModel = { + status: 'warning', + title: 'Your Claude sign-in expired', + detail: 'Renew to keep working. Your conversation is kept.', + description: null, + primary: { label: 'Renew', action: { kind: 'renew' } }, + code: null, + paste: null, + otherWays: false, + announcement: 'Your Claude sign-in expired.' +} + +const model = (setup: OrcaCloudAgentSetup) => + cloudRenewInPlaceModel({ agent: 'claude', expired: EXPIRED, setup, t }) + +describe('cloudRenewInPlaceModel (design-squad per-step copy)', () => { + it("1 expired: the surface's own strip, [Renew], no Settings link", () => { + expect(model({ phase: 'idle' })).toBe(EXPIRED) + expect(model({ phase: 'saved' })).toBe(EXPIRED) + }) + + it('2 browser: same title, paste code, [Finish renewing] [Open again], "Renew in Settings"', () => { + const shown = model({ phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true }) + expect(shown.title).toBe('Your Claude sign-in expired') + expect(shown.detail).toBe('Finish in your browser, then paste the code here.') + expect(shown.paste?.setupCode).toEqual({ + placeholder: 'Paste code', + submitLabel: 'Finish renewing' + }) + expect(shown.primary).toMatchObject({ label: 'Open again', action: { kind: 'reopenSetup' } }) + expect(shown.settingsLink).toBe('Renew in Settings') + expect(JSON.stringify(shown)).not.toContain('Finish setup') + }) + + it('3 checking: the field and its button disabled', () => { + const shown = model({ + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'checking' + }) + expect(shown.paste).toMatchObject({ submitting: true }) + expect(shown.paste?.setupCode?.submitLabel).toBe('Checking the code…') + }) + + it('4 rejected: "Code didn\'t work", the newest-code detail, [Open again] and the Settings link', () => { + const shown = model({ + phase: 'browser', + startedAt: 0, + canReopen: true, + acceptsCode: true, + code: 'rejected' + }) + expect(shown.title).toBe('Code didn’t work') + expect(shown.detail).toBe('Paste the newest code from the sign-in page, or open it again.') + expect(shown.paste?.refusal).toBeTruthy() + expect(shown.primary?.action.kind).toBe('reopenSetup') + expect(shown.settingsLink).toBe('Renew in Settings') + }) + + it('a setup that failed outright starts again from Renew', () => { + const shown = model({ phase: 'failed', error: 'denied' }) + expect(shown.status).toBe('error') + expect(shown.primary?.action.kind).toBe('renew') + }) +}) + +describe('cloudRenewInPlaceActions', () => { + const surface = (): CloudStripActions => ({ + primary: vi.fn(), + submitCode: vi.fn(), + pasteCode: vi.fn(), + useCode: vi.fn(), + useApiKey: vi.fn(), + cancel: vi.fn() + }) + const actions = (setup: OrcaCloudAgentSetup, own = surface(), openSettings = vi.fn()) => ({ + own, + openSettings, + built: cloudRenewInPlaceActions({ + agent: 'claude', + setup, + surface: own, + openSettings, + copyText: vi.fn(), + openLink: vi.fn() + }) + }) + beforeEach(() => { + store.run.mockClear() + store.stop.mockClear() + api.reopen.mockClear() + }) + + it('Renew runs the setup here (never the Settings page or the sign-in sheet)', () => { + const { built, own, openSettings } = actions({ phase: 'idle' }) + built.primary({ kind: 'renew' }) + expect(store.run).toHaveBeenCalledExactlyOnceWith(['claude']) + expect(own.primary).not.toHaveBeenCalled() + expect(openSettings).not.toHaveBeenCalled() + }) + + it('Open again reopens the setup page; "Renew in Settings" opens Settings', () => { + const { built, openSettings } = actions({ phase: 'browser', startedAt: 0, canReopen: true }) + built.primary({ kind: 'reopenSetup' }) + expect(api.reopen).toHaveBeenCalledExactlyOnceWith({ agent: 'claude' }) + built.openSettings?.() + expect(openSettings).toHaveBeenCalledOnce() + }) + + it('6: × mid-flow stops the setup (back to step 1) and never dismisses the strip', () => { + const { built, own } = actions({ phase: 'browser', startedAt: 0, canReopen: true }) + built.cancel() + expect(store.stop).toHaveBeenCalledExactlyOnceWith('claude') + expect(own.cancel).not.toHaveBeenCalled() + }) + + it('outside a renewal, the surface keeps its own × and Restart', () => { + const { built, own } = actions({ phase: 'idle' }) + built.cancel() + built.primary({ kind: 'restart' }) + expect(store.stop).not.toHaveBeenCalled() + expect(own.cancel).toHaveBeenCalledOnce() + expect(own.primary).toHaveBeenCalledExactlyOnceWith({ kind: 'restart' }) + }) +}) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts new file mode 100644 index 00000000000..6934dc64204 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts @@ -0,0 +1,161 @@ +// CHAT-RENEW-INPLACE (Option A, design-squad per-step copy): every "sign-in expired" strip — a held +// launch, a running terminal, a structured chat — renews in place. Renew runs the same Orca Cloud +// setup as "Set up once" right in the strip (re-running it replaces the account's expired sign-in), +// instead of sending the person to Settings. One model and one set of actions for every surface, so +// the terminal and the chat can't drift apart. +// 1 expired: [Renew] 2 browser: paste code, [Finish renewing] [Open again] × +// 3 checking: field and buttons disabled 4 rejected: "Code didn't work", [Open again] +// 5 renewed: the surface's own renewed state (held: Start; terminal: Restart; chat: next message) +// × mid-flow stops the setup and goes back to step 1; it never dismisses the strip. +import type { Translate } from '@/i18n/use-translate' +import { useAppStore } from '@/store' +import { + runOrcaCloudAgentSetups, + stopOrcaCloudAgentSetups +} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' +import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status' +import type { CloudStripActions } from './CloudAgentSignInStrip' +import type { CloudStripModel } from './cloud-agent-strip-model' +import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model' +import { cloudSetupStripActions } from './cloud-setup-strip-actions' +import { agentSignInCardActions } from '../agent-sign-in/AgentSignInPanePortals' + +/** The renewal's own setup is under way (steps 2-4): the strip shows it instead of step 1. */ +export function renewInPlaceRunning(setup: OrcaCloudAgentSetup): boolean { + return setup.phase !== 'idle' && setup.phase !== 'saved' +} + +/** + * Steps 1-4 over a surface's "expired" model (step 1 keeps the surface's own detail: running "Renew + * to keep working…", held "Renew to start {{agent}}."). Step 5 is each surface's renewed state. + */ +export function cloudRenewInPlaceModel(args: { + agent: HostAgentSignInAgent + expired: CloudStripModel + setup: OrcaCloudAgentSetup + t: Translate +}): CloudStripModel { + const { expired, setup, t } = args + if (!renewInPlaceRunning(setup)) { + return expired + } + const flow = cloudAgentSetupStripModel({ agent: args.agent, setup, ready: false, t }) + const settingsLink = t('agentSignInStrip.renewInSettings', 'Renew in Settings') + // A failed setup keeps its own words; a refused code says "Code didn't work" (as #484). + const keepsTitle = + setup.phase === 'failed' || (setup.phase === 'browser' && setup.code === 'rejected') + const finishRenewing = t('agentSignInStrip.finishRenewing', 'Finish renewing') + const paste = flow.paste + ? { + ...flow.paste, + // Never the setup's "Finish setup" (design-squad). + ...(flow.paste.setupCode + ? { + setupCode: { + ...flow.paste.setupCode, + submitLabel: + setup.phase === 'browser' && setup.code === 'checking' + ? flow.paste.setupCode.submitLabel + : finishRenewing + } + } + : {}), + ...(flow.paste.submitLabel ? { submitLabel: finishRenewing } : {}) + } + : null + return { + ...flow, + title: keepsTitle ? flow.title : expired.title, + paste, + // A setup that failed outright starts again from Renew. + primary: + setup.phase === 'failed' && flow.primary?.action.kind === 'setUpOnce' + ? { ...flow.primary, action: { kind: 'renew' } } + : flow.primary, + ...(setup.phase === 'browser' || setup.phase === 'paste' ? { settingsLink } : {}), + announcement: keepsTitle ? flow.announcement : `${expired.title}. ${flow.announcement}` + } +} + +/** + * Renew starts the setup here; Open again, the code and the paste go to it; × stops it (back to + * step 1). With no renewal running, everything else is the surface's own (its × or Restart). + */ +export function cloudRenewInPlaceActions(args: { + agent: HostAgentSignInAgent + setup: OrcaCloudAgentSetup + /** The surface's own actions, used outside the renewal (Restart, a renewed strip's ×). */ + surface: CloudStripActions + /** "Renew in Settings": the same renewal in Settings › Accounts › Orca Cloud. */ + openSettings: () => void + copyText: (text: string) => void + openLink: (url: string) => void +}): CloudStripActions { + const running = renewInPlaceRunning(args.setup) + const flow = cloudSetupStripActions({ + agent: args.agent, + // Renewing holds nothing itself: Start (step 5) belongs to the surface. + holdKey: '', + setupRunning: running, + copyText: args.copyText, + openLink: args.openLink, + useApiKey: args.surface.useApiKey, + keepHoldOnCancel: true + }) + return { + ...args.surface, + primary: (action) => { + if (action.kind === 'renew') { + runOrcaCloudAgentSetups([args.agent]) + } else if (action.kind === 'reopenSetup' || action.kind === 'copyAndOpen') { + flow.primary(action) + } else { + args.surface.primary(action) + } + }, + submitCode: running ? flow.submitCode : args.surface.submitCode, + submitSetupCode: flow.submitSetupCode, + // "Copy link" beside Open again: the renewal's sign-in page, as for "Set up once". + linkFor: running ? flow.linkFor : args.surface.linkFor, + pasteCode: running ? flow.pasteCode : args.surface.pasteCode, + openSettings: args.openSettings, + cancel: () => (running ? stopOrcaCloudAgentSetups(args.agent) : args.surface.cancel()) + } +} + +/** "Renew in Settings": Settings › Accounts › Orca Cloud, where the same renewal runs. */ +export function openOrcaCloudAccountsSettings(): void { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + store.openSettingsPage() +} + +/** A surface's expired strip, renewing in place: its model (steps 1-4) and actions together. */ +export function withRenewInPlace(args: { + agent: HostAgentSignInAgent + setup: OrcaCloudAgentSetup + model: CloudStripModel + actions: CloudStripActions + /** The pane or chat key, for the sign-in page's copy and open. */ + key: string + t: Translate +}): { model: CloudStripModel; actions: CloudStripActions } { + const page = agentSignInCardActions(args.key) + return { + model: cloudRenewInPlaceModel({ + agent: args.agent, + expired: args.model, + setup: args.setup, + t: args.t + }), + actions: cloudRenewInPlaceActions({ + agent: args.agent, + setup: args.setup, + surface: args.actions, + openSettings: openOrcaCloudAccountsSettings, + copyText: page.copyText, + openLink: page.openLink + }) + } +} diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts index ab90eac24da..7325eea6371 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts @@ -31,7 +31,7 @@ export function cloudSetupStripActions(args: { keepHoldOnCancel?: boolean }): CloudStripActions { const { agent, holdKey } = args - const api = window.api.orcaCloudAgents + const api = window.api?.orcaCloudAgents return { primary: (action) => { if (action.kind === 'setUpOnce') { diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts index ef46a8abf49..92330c53794 100644 --- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts +++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts @@ -4,7 +4,10 @@ // with the renewed sign-in. import { useAppStore } from '@/store' import { useTranslate } from '@/i18n/use-translate' -import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status' +import { + HOST_AGENT_SIGN_IN_AGENT_NAME, + type HostAgentSignInAgent +} from '../../../../shared/host-agent-sign-in-status' import { parseExecutionHostId } from '../../../../shared/execution-host' import { useCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold' import { @@ -15,6 +18,7 @@ import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environmen import { clearCloudRunningSignout, cloudRunningStrip, + type CloudRunningStrip, useCloudRunningAccountReads, useCloudRunningSignoutVersion, useWorkspaceUsesAccount @@ -22,12 +26,7 @@ import { import type { CloudStripActions } from './CloudAgentSignInStrip' import type { CloudHeldStrip } from './use-cloud-held-strip' import { cloudChatRunningStripModel } from './cloud-running-strip-model' - -function openAccounts(): void { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) - store.openSettingsPage() -} +import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place' /** The Orca Cloud environment a structured chat's tab runs on, or null. */ function useChatEnvironmentId(tabId: string): string | null { @@ -43,27 +42,33 @@ function useChatEnvironmentId(tabId: string): string | null { }) } -/** - * statusKey: the chat's agent-status key (its tab and session); holdKey: its launch-hold key. A held - * launch shows the held strip instead. - */ -export function useCloudChatRunningStrip(args: { +type ChatAccountArgs = { tabId: string statusKey: string holdKey: string -}): CloudHeldStrip | null { - const t = useTranslate() + /** The chat's own agent: an idle chat (no message yet) has no status entry to name it. */ + agent?: string +} + +/** + * The RA-11 decision for a structured chat: expired / renewed / none. An idle chat with no status + * entry is decided from its own agent, so the strip shows as soon as the account expires, before a + * first message could fail. A held launch wins (null here). + */ +export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStrip | null { const revision = useCloudRunningSignoutVersion() const environmentId = useChatEnvironmentId(args.tabId) const cloud = environmentId !== null && isOrcaCloudEnvironmentId(environmentId) - const entry = useAppStore((state) => + const status = useAppStore((state) => cloud ? (state.agentStatusByPaneKey[args.statusKey] ?? null) : null ) + const agentType = status?.agentType ?? args.agent const agent: HostAgentSignInAgent | null = - entry?.agentType === 'claude' ? 'claude' : entry?.agentType === 'codex' ? 'codex' : null - const running = cloud && agent !== null - useCloudRunningAccountReads(running) - const accounts = useOrcaCloudAgentAccounts(running) + agentType === 'claude' ? 'claude' : agentType === 'codex' ? 'codex' : null + const watched = cloud && agent !== null + // Focus and the 30 s poll keep the account read fresh for any open cloud chat of an account agent. + useCloudRunningAccountReads(watched) + const accounts = useOrcaCloudAgentAccounts(watched) // D13: only a workspace that runs the agent on the person's own account (never shared or keyed). const credential = agent && accounts.load.kind === 'ready' ? accounts.load.status.credentials[agent] : null @@ -73,18 +78,19 @@ export function useCloudChatRunningStrip(args: { credential.state !== 'expiring' && credential.state !== 'none' const usesAccount = useWorkspaceUsesAccount( - running ? environmentId : null, + watched ? environmentId : null, agent, expired, orcaCloudAgentAccountsLoadedAt() ) const held = useCloudAgentHold(args.holdKey) !== null - if (!running) { + if (!watched || !agent) { return null } - const strip = cloudRunningStrip({ + return cloudRunningStrip({ paneKey: args.statusKey, - entry, + // An idle chat (no message yet) has no status entry: decided from its own agent. + entry: status ?? { agentType: agent, state: 'waiting' }, accounts, held, now: orcaCloudAgentAccountsLoadedAt(), @@ -92,11 +98,18 @@ export function useCloudChatRunningStrip(args: { revision, turnEndedFallback: true }) +} + +/** The strip a structured chat shows for its account while its agent runs or waits idle. */ +export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip | null { + const t = useTranslate() + const strip = useCloudChatAccountStrip(args) + const accounts = useOrcaCloudAgentAccounts(strip !== null) if (!strip) { return null } const actions: CloudStripActions = { - primary: (action) => (action.kind === 'renew' ? openAccounts() : undefined), + primary: (action) => (action.kind === 'renew' ? openOrcaCloudAccountsSettings() : undefined), submitCode: () => undefined, pasteCode: () => undefined, useCode: () => undefined, @@ -104,5 +117,32 @@ export function useCloudChatRunningStrip(args: { // Expired has no ×; renewed's × dismisses. cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(args.statusKey) : undefined) } - return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions } + const model = cloudChatRunningStripModel(strip, t) + // CHAT-RENEW-INPLACE: the chat's "sign-in expired · Renew" renews right here. + const shown = + strip.phase === 'expired' + ? withRenewInPlace({ + agent: strip.agent, + setup: accounts.setups[strip.agent], + model, + actions, + key: args.statusKey, + t + }) + : { model, actions } + return { ...shown, prompt: null } +} + +/** + * While the chat's account is expired, typing stays open and only Send is held, with this hint + * (the person's draft is never lost); Renew lifts it at once. + */ +export function useCloudChatSendBlock(args: ChatAccountArgs): string | undefined { + const t = useTranslate() + const strip = useCloudChatAccountStrip(args) + return strip?.phase === 'expired' + ? t('orcaCloud.chatSend.renewAbove', 'Renew {{agent}} sign-in above to send', { + agent: HOST_AGENT_SIGN_IN_AGENT_NAME[strip.agent] + }) + : undefined } diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts index fa9989f86ae..5711413b7db 100644 --- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts +++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts @@ -21,6 +21,11 @@ import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model' import { cloudSetupStripActions } from './cloud-setup-strip-actions' +import { + cloudRenewInPlaceActions, + cloudRenewInPlaceModel, + openOrcaCloudAccountsSettings +} from './cloud-renew-in-place' export type CloudHeldStrip = { model: CloudStripModel @@ -70,7 +75,7 @@ export function useCloudHeldStrip( const setupHold = hold?.reason === 'notSetUp' || awaiting !== null const setup = accounts.setups[agent] // HELD-STALE-COVERED: the setup just finished here: "is set up · Start" (the account, read fresh). - const model = + const shownBeforeRenew = awaiting === 'setUp' ? cloudAgentSetupStripModel({ agent, setup: accounts.setups[agent], ready: true, t }) : awaiting @@ -95,6 +100,11 @@ export function useCloudHeldStrip( hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'), t }) + // CHAT-RENEW-INPLACE: a launch held on an expired sign-in renews right here (steps 1-4). + const renewing = hold?.reason === 'accountExpired' + const model = renewing + ? cloudRenewInPlaceModel({ agent, expired: shownBeforeRenew, setup, t }) + : shownBeforeRenew const setupRunning = setup.phase !== 'idle' && setup.phase !== 'saved' && setup.phase !== 'failed' const holdActions: CloudStripActions | null = setupHold ? cloudSetupStripActions({ @@ -107,7 +117,7 @@ export function useCloudHeldStrip( keepHoldOnCancel: chat }) : null - const actions: CloudStripActions = holdActions ?? { + const gateOrRecovery: CloudStripActions = { primary: (action) => { // RA-11's restart belongs to the running-pane strip; a held launch never offers it. if ( @@ -152,9 +162,21 @@ export function useCloudHeldStrip( } } } + const actions: CloudStripActions = + holdActions ?? + (renewing + ? cloudRenewInPlaceActions({ + agent, + setup, + surface: gateOrRecovery, + openSettings: openOrcaCloudAccountsSettings, + copyText: gateActions.copyText, + openLink: gateActions.openLink + }) + : gateOrRecovery) // A held chat's strip has nothing to cancel unless a setup is running: no × (Escape is a no-op). const shownModel = - chat && hold && !(setupHold && setupRunning) ? { ...model, noClose: true } : model + chat && hold && !((setupHold || renewing) && setupRunning) ? { ...model, noClose: true } : model return { key, active, diff --git a/src/renderer/src/components/billing/team/TeamDialogContent.test.tsx b/src/renderer/src/components/billing/team/TeamDialogContent.test.tsx new file mode 100644 index 00000000000..d9d250f4afb --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamDialogContent.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { Dialog } from '@/components/ui/dialog' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TeamDialogContent } from './TeamDialogHost' + +afterEach(cleanup) + +// TEAM-MODAL-CENTER: the Team dialog (opened from the account menu, Create a team, or after +// Checkout; all go through this one surface) is centred in the window like every other dialog. +it('centres the Team dialog in the window instead of pinning it near the top', () => { + render( + + + +

Team

+
+
+
+ ) + const dialog = document.querySelector('[data-team-dialog]')! + // Portaled to the document, not into the menu that opened it. + expect(dialog.closest('[data-slot="dropdown-menu-content"]')).toBeNull() + const classes = dialog.className.split(/\s+/) + expect(classes).toContain('fixed') + expect(classes).toContain('top-[50%]') + expect(classes).toContain('left-[50%]') + expect(classes).toContain('translate-y-[-50%]') + expect(classes).toContain('translate-x-[-50%]') + expect(classes).not.toContain('top-[6vh]') + expect(classes).not.toContain('translate-y-0') +}) diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 0dd99da27e8..a6333df3286 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -234,6 +234,13 @@ export function TeamPageBody({ openTeammateProfile(userId) }} onDismissArrival={clearTeamPageArrival} + onOpenEnterprise={() => { + // SSO, SCIM and the audit log live in Settings › Enterprise (#614); the dialog points there. + closeTeamPage() + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'cloud-enterprise', repoId: null }) + store.openSettingsPage() + }} inline={inline} dangerZone={ org && teamDelete.check ? ( @@ -251,13 +258,18 @@ export function TeamPageBody({ ) } -/** The Team page's dialog surface: top-anchored so loading and checkout arrivals never jump. */ +/** + * The Team page's dialog surface, centred in the window like every other dialog, whichever entry + * point opened it (account menu, Create a team, the post-checkout hand-off). It used to be pinned + * 6vh from the top (`top-[6vh] translate-y-0`), so it sat high on screen (TEAM-MODAL-CENTER). + */ export function TeamDialogContent({ children }: { children: React.ReactNode }): React.JSX.Element { const t = useTranslate() return ( { event.preventDefault() if (event.currentTarget instanceof HTMLElement) { diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index fe4e0510d5b..2d037a3e4b0 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -89,7 +89,8 @@ const base: TeamPageViewProps = { onSeePlans: noop, onManageBilling: noop, onCreateWorkspace: noop, - onDismissArrival: noop + onDismissArrival: noop, + onOpenEnterprise: noop } function Page(props: Partial): React.JSX.Element { @@ -104,6 +105,21 @@ function Page(props: Partial): React.JSX.Element { export const ownerOnTeam = defineFixture({ width, render: () => }) +/** TEAM-MODAL-CENTER: in a tall window the dialog sits in the middle, not pinned near the top. */ +export const centeredInWindow = defineFixture({ + width, + render: () => ( +
+ +
+ ) +}) + export const justUpgraded = defineFixture({ width, render: () => diff --git a/src/renderer/src/components/billing/team/TeamPageView.test.tsx b/src/renderer/src/components/billing/team/TeamPageView.test.tsx index 5ae89ebf7cf..37311a6df5e 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.test.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles' import { sampleTeam } from '../plan-billing-fixtures' @@ -61,3 +61,40 @@ it('keeps the header to the name and counts: no role sentence, no Plan & billing // Seat counts and prices live only in Plan & billing. expect(screen.queryByText(/open seat/)).toBeNull() }) + +it('points owners and admins to Settings › Enterprise with one plain button', () => { + const onOpenEnterprise = vi.fn() + for (const viewerRole of ['owner', 'admin'] as const) { + render( + , + { wrapper: TooltipProvider } + ) + const row = screen.getByRole('region', { + name: 'Single sign-on, directory sync and the audit log' + }) + // No Copy link split: the row has exactly one control. + expect(row.querySelectorAll('button')).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Open Enterprise settings' })) + cleanup() + } + expect(onOpenEnterprise).toHaveBeenCalledTimes(2) +}) + +it('hides the Enterprise row from plain members', () => { + render( + {}} + />, + { wrapper: TooltipProvider } + ) + expect(screen.queryByText('Single sign-on, directory sync and the audit log')).toBeNull() +}) diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index ed80617ac47..71edf46cc16 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { RefreshCw } from 'lucide-react' +import { KeyRound, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' @@ -57,6 +57,8 @@ export type TeamPageViewProps = { inline?: { onOpenPlanBilling: () => void } /** Owners in Settings › Team: "Delete ", after "Leave " (TEAM-DELETE). */ dangerZone?: React.ReactNode + /** Opens Settings › Enterprise (SSO, SCIM, audit log). Shown to owners and admins only. */ + onOpenEnterprise?: () => void } function SkeletonRows(): React.JSX.Element { @@ -110,6 +112,10 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const rows = roster ? teamRows(roster, props.viewerUserId) : [] const selfRow = rows.find((row) => row.self) const viewerRole = roster?.viewerRole ?? 'member' + // Matches who sees Settings › Enterprise: team owners and admins (Enterprise or not). + const showEnterprise = + Boolean(props.onOpenEnterprise) && + (roster?.viewerRole === 'owner' || roster?.viewerRole === 'admin') const canLeave = Boolean(selfRow) && (viewerRole !== 'owner' || (roster ? otherOwnerCount(roster, props.viewerUserId) > 0 : false)) @@ -278,8 +284,33 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { ) : null} - {props.dangerZone ? ( + {showEnterprise || props.dangerZone ? (
+ {showEnterprise ? ( + // One quiet pointer to Settings › Enterprise, which owns SSO, SCIM and the audit log + // (and shows the upsell to teams without Enterprise). Same audience as that section. +
+

+ + {t( + 'auto.components.billing.team.enterpriseRowTitle', + 'Single sign-on, directory sync and the audit log' + )} +

+ +
+ ) : null} {props.dangerZone}
) : null} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx index 039c509cca8..fd68b8aac9e 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx @@ -124,12 +124,11 @@ describe('Open cloud workspace…', () => { expect(getCloudWorkspacePickerRequest()).not.toBeNull() }) - it('with everything in the sidebar, says so instead of inviting a first workspace', async () => { + it('with everything in the sidebar, says there is nothing else to open', async () => { listResult = { ok: true, value: [MINE] } renderPicker() act(() => openCloudWorkspacePicker()) - expect(await screen.findByText('Your cloud workspaces are already in the sidebar')).toBeTruthy() - expect(screen.queryByRole('button', { name: 'New cloud workspace' })).toBeNull() + expect(await screen.findByText('Nothing else to open')).toBeTruthy() }) it('a list that could not be read offers Try again', async () => { diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx index f4fcede68bb..7e269c0155d 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.test.tsx @@ -58,19 +58,27 @@ it('at the running limit, Show cloud workspaces opens the picker on your running closeCloudWorkspacePicker() }) -it('with none at all, invites the first cloud workspace with one button (JA-30)', () => { +it('with none at all, says so and offers New cloud workspace as the primary action', () => { const onCreate = vi.fn() render() - expect(screen.getByText('Start your first cloud workspace')).toBeTruthy() + expect(screen.getByText('No cloud workspaces yet')).toBeTruthy() + expect(screen.getByText('Start one and it shows up in your sidebar.')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'New cloud workspace' })) expect(onCreate).toHaveBeenCalledTimes(1) }) -it('every other empty list keeps its own copy and offers no create button', () => { +it('with everything in the sidebar, says there is nothing else and keeps a secondary New', () => { render() - expect(screen.getByText('Your cloud workspaces are already in the sidebar')).toBeTruthy() - expect(screen.queryByRole('button')).toBeNull() - cleanup() + expect(screen.getByText('Nothing else to open')).toBeTruthy() + expect( + screen.getByText('Every cloud workspace you can open is already in your sidebar.') + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'New cloud workspace' }).getAttribute('data-variant') + ).toBe('outline') +}) + +it('a search with no match keeps its own copy and offers no create button', () => { render() expect(screen.getByText('Nothing matches your search')).toBeTruthy() expect(screen.queryByRole('button')).toBeNull() diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx index e2a7dff424d..f9f2e52ef2b 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesStates.tsx @@ -104,7 +104,7 @@ export function PickerEmptyState({ onCreate }: { reason: PickerEmptyReason - /** Offered only when there are none at all: start one. */ + /** Start one: primary with none at all, secondary when all are in the sidebar. */ onCreate?: () => void }): React.JSX.Element { const t = useTranslate() @@ -128,13 +128,10 @@ export function PickerEmptyState({ ] case 'all-in-sidebar': return [ + t('components.cloudWorkspaces.picker.emptyNothingElseTitle', 'Nothing else to open'), t( - 'components.cloudWorkspaces.picker.emptyAllInSidebarTitle', - 'Your cloud workspaces are already in the sidebar' - ), - t( - 'components.cloudWorkspaces.picker.emptyAllInSidebarBody', - 'Workspaces teammates share, and ones you start on another computer, show up here.' + 'components.cloudWorkspaces.picker.emptyNothingElseBody', + 'Every cloud workspace you can open is already in your sidebar.' ) ] case 'none-running': @@ -159,10 +156,10 @@ export function PickerEmptyState({ ] case 'none': return [ - t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'), + t('components.cloudWorkspaces.picker.emptyNoneTitle', 'No cloud workspaces yet'), t( - 'components.cloudWorkspaces.emptyStartBody', - 'It runs in a sandbox, so your agents keep working even when this computer is off.' + 'components.cloudWorkspaces.picker.emptyNoneBody', + 'Start one and it shows up in your sidebar.' ) ] } @@ -171,8 +168,15 @@ export function PickerEmptyState({

{title}

{body}

- {reason.kind === 'none' && onCreate ? ( - diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts new file mode 100644 index 00000000000..d64be4157e1 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts @@ -0,0 +1,68 @@ +// A cloud workspace's share link: the org slug + W-number link when known, else the sandbox-id form. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' + +// How long Copy link waits on the control plane before settling for the sandbox-id link. +const LINK_LOOKUP_MS = 3_000 + +async function lookUpLink(id: string): Promise { + let timer: ReturnType | undefined + try { + const fetched = await Promise.race([ + window.api.cloudWorkspaces.get(id), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) + }) + ]) + if (!fetched?.ok) { + return null + } + rememberCloudWorkspaces([fetched.value]) + return fetched.value.link ?? null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +/** + * The org slug + W-number link when the control plane gave one, else the sandbox id form, which + * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, + * offline, signed out) still gets a working link. + */ +export async function shareLinkFor(id: string, chatId?: string | null): Promise { + const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) + return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) +} + +export async function copyCloudWorkspaceLink( + id: string, + chatId?: string | null, + options: { quiet?: boolean } = {} +): Promise { + const link = await shareLinkFor(id, chatId) + try { + // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a + // focused document with live user activation; after the link lookup (or from the menu or a + // shortcut) Electron rejects it with "Document is not focused". + await window.api.ui.writeClipboardText(link) + } catch (error) { + console.warn('[cloud-workspaces] copy link failed', error) + toast.error( + translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") + ) + return false + } + if (options.quiet) { + return true + } + toast.success( + chatId + ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') + : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') + ) + return true +} diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts index e2b376a817e..0ae44d82588 100644 --- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts +++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts @@ -11,8 +11,6 @@ import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-a import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' import { getWorktreeExecutionHostId, toRuntimeExecutionHostId @@ -66,7 +64,20 @@ function isPairingRejected(error: unknown): boolean { return error instanceof Error && /\b(unauthori[sz]ed|device_revoked)\b/i.test(error.message) } -async function connectAndReveal(environmentId: string, name: string): Promise { +export type OpenCloudWorkspaceOptions = { + /** + * The server this open connects to, before any of its rows load. A GitHub-only create's sheet + * hides that server's rows until it hands off to them, so its pending row never doubles up. + */ + onHost?: (hostId: string) => void +} + +async function connectAndReveal( + environmentId: string, + name: string, + options: OpenCloudWorkspaceOptions = {} +): Promise { + options.onHost?.(toRuntimeExecutionHostId(environmentId)) const environments = await window.api.runtimeEnvironments.list() useAppStore.getState().setRuntimeEnvironments(environments) const environment = environments.find((e) => e.id === environmentId) @@ -147,7 +158,11 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise { +async function pairAndReveal( + workspace: CloudWorkspace, + name: string, + options: OpenCloudWorkspaceOptions +): Promise { const api = window.api.cloudWorkspaces const binding = (await api.environmentBindings())[workspace.id] if (binding) { @@ -161,7 +176,7 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise undefined) - return await connectAndReveal(added.environment.id, name) + return await connectAndReveal(added.environment.id, name, options) } -async function openOnce(workspace: CloudWorkspace): Promise { +async function openOnce( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions +): Promise { const name = cloudWorkspaceTitle(workspace) try { if (workspace.access === 'owner') { @@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise { ) } } - return await pairAndReveal(workspace, name) + return await pairAndReveal(workspace, name, options) } catch (error) { // Raw transport/RPC messages are not user copy; keep them for diagnostics only. console.warn('[cloud-workspaces] open failed', error) @@ -262,75 +280,18 @@ async function openOnce(workspace: CloudWorkspace): Promise { // instead of minting parallel grants. const opening = new Map>() -export function openCloudWorkspace(workspace: CloudWorkspace): Promise { +export function openCloudWorkspace( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions = {} +): Promise { const existing = opening.get(workspace.id) if (existing) { return existing } - const attempt = openOnce(workspace).finally(() => opening.delete(workspace.id)) + const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id)) opening.set(workspace.id, attempt) return attempt } -// How long Copy link waits on the control plane before settling for the sandbox-id link. -const LINK_LOOKUP_MS = 3_000 - -async function lookUpLink(id: string): Promise { - let timer: ReturnType | undefined - try { - const fetched = await Promise.race([ - window.api.cloudWorkspaces.get(id), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) - }) - ]) - if (!fetched?.ok) { - return null - } - rememberCloudWorkspaces([fetched.value]) - return fetched.value.link ?? null - } catch { - return null - } finally { - clearTimeout(timer) - } -} - -/** - * The org slug + W-number link when the control plane gave one, else the sandbox id form, which - * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, - * offline, signed out) still gets a working link. - */ -export async function shareLinkFor(id: string, chatId?: string | null): Promise { - const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) - return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) -} - -export async function copyCloudWorkspaceLink( - id: string, - chatId?: string | null, - options: { quiet?: boolean } = {} -): Promise { - const link = await shareLinkFor(id, chatId) - try { - // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a - // focused document with live user activation; after the link lookup (or from the menu or a - // shortcut) Electron rejects it with "Document is not focused". - await window.api.ui.writeClipboardText(link) - } catch (error) { - console.warn('[cloud-workspaces] copy link failed', error) - toast.error( - translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") - ) - return false - } - if (options.quiet) { - return true - } - toast.success( - chatId - ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') - : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') - ) - return true -} +// Copy link lives beside it; re-exported so callers keep one import. +export { copyCloudWorkspaceLink, shareLinkFor } from './cloud-workspace-share-link' diff --git a/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts new file mode 100644 index 00000000000..b40318b1ab7 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts @@ -0,0 +1,15 @@ +import { toast } from 'sonner' +import type { Translate } from '@/i18n/use-translate' + +/** + * "Shared with your team", with the one thing that changes for the owner (SEC-5): their personal + * secrets stop being injected once teammates can open the workspace. + */ +export function toastSharedWithTeam(t: Translate): void { + toast.success(t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), { + description: t( + 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', + 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' + ) + }) +} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx index abd7ac863a1..7c4b3ead91d 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx @@ -21,6 +21,7 @@ import type { RowActions } from './CloudWorkspaceRowMenu' import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog' import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet' import { useCloudReadPanel } from './use-cloud-read-panel' +import { toastSharedWithTeam } from './shared-with-team-toast' /** Names for every member id the rows show, and whether you are a team owner or admin. */ export function useMemberDirectory( @@ -130,9 +131,7 @@ export function useCloudWorkspaceRowActions({ void refreshCloudMessageSenders().catch(() => undefined) } if (change.visibility === 'team') { - toast.success( - t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') - ) + toastSharedWithTeam(t) } else if (change.visibility === 'private') { toast.success( t( diff --git a/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx b/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx index fb311bf91e8..604f7eff689 100644 --- a/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx +++ b/src/renderer/src/components/native-chat/host-access/NativeChatInstallSlot.tsx @@ -1,6 +1,8 @@ import { translate } from '@/i18n/i18n' import { useCloudChatHoldHint } from '../../agent-sign-in-strip/use-cloud-chat-hold-hint' import { structuredCloudHoldKey } from '@/lib/orca-cloud/cloud-structured-launch-hold' +import { useCloudChatSendBlock } from '../../agent-sign-in-strip/use-cloud-chat-running-strip' +import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection' import { useTranslate, type Translate } from '@/i18n/use-translate' import { managedRuntimeInstallCard, @@ -33,7 +35,10 @@ const RESERVED: NativeChatInstallSlotState = { kind: 'reserved' } /** Reserve unknown readiness with an editable composer and held sends; missing runtimes show installation, while read failures leave it alone. */ export function useNativeChatInstallSlot( - props: Pick, + props: Pick< + NativeChatStructuredViewProps, + 'target' | 'agent' | 'isVisible' | 'sessionId' | 'tabId' + >, agentLabel: string, layout: ManagedRuntimeInstallLayout ): { @@ -49,7 +54,15 @@ export function useNativeChatInstallSlot( hostProgram ) // CHAT-BANNERS: an Orca Cloud launch held on the chat's strip has no agent to send to yet. - const cloudHoldHint = useCloudChatHoldHint(structuredCloudHoldKey(props.sessionId), '') + const cloudHoldKey = structuredCloudHoldKey(props.sessionId) + const cloudHoldHint = useCloudChatHoldHint(cloudHoldKey, '') + // While its Orca Cloud account is expired, the chat takes typing but holds Send (draft kept). + const cloudSendBlock = useCloudChatSendBlock({ + tabId: props.tabId, + statusKey: structuredAgentSessionPaneKey(props.tabId, props.sessionId), + holdKey: cloudHoldKey, + agent: props.agent + }) const unavailableHint = cloudHoldHint ?? (slot.kind === 'install' @@ -57,7 +70,8 @@ export function useNativeChatInstallSlot( : undefined) return { slot, - sendBlockedReason: unavailableHint ?? installSlotSendBlockedReason(slot, agentLabel, t), + sendBlockedReason: + unavailableHint ?? cloudSendBlock ?? installSlotSendBlockedReason(slot, agentLabel, t), unavailableHint } } diff --git a/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx index 5ca92b45986..153cafb5635 100644 --- a/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/pane/actions/NativeChatPaneHeader.tsx @@ -152,6 +152,7 @@ export function nativeChatPaneHeader( tabId={view.tabId} paneKey={structuredCloudHoldKey(view.sessionId)} statusKey={structuredAgentSessionPaneKey(view.tabId, view.sessionId)} + agent={view.agent} /> ) const header = diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.fixture.tsx index e3c46db051f..256cc931114 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.fixture.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.fixture.tsx @@ -1,6 +1,7 @@ import { defineFixture } from '../../component-fixtures/component-fixture' import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage' import { translate } from '@/i18n/i18n' +import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access' import { cloudSetupNoticesView } from './CloudSetupNotices' import { CloudToastCard, type CloudToastView } from './cloud-toast-stack' @@ -34,12 +35,58 @@ const FETCH: CloudSetupNotice = { } } -function notices(list: readonly CloudSetupNotice[], installing = false, checkingFetch = false) { +const DISK_LOW: CloudSetupNotice = { + id: 'disk.free', + kind: 'disk', + check: { + id: 'disk.free', + category: 'disk', + status: 'fail', + message: 'Only 0.8 GB free.', + code: 'diskVeryLow', + params: { free: '0.8' } + } +} + +const TOOL_MISSING: CloudSetupNotice = { + id: 'tool.pnpm', + kind: 'tool', + check: { + id: 'tool.pnpm', + category: 'tools', + status: 'fail', + message: 'pnpm is not on PATH, but pnpm-lock.yaml needs it.', + code: 'toolMissing', + params: { command: 'pnpm', reason: 'pnpm-lock.yaml' } + } +} + +function notices( + list: readonly CloudSetupNotice[], + options: { + installing?: boolean + checkingFetch?: boolean + access?: CloudPushAccess + repoUrl?: string + } = {} +) { return cloudSetupNoticesView( - { repoUrl: 'https://github.com/stablyai/orca', environmentId: 'env' }, + { repoUrl: options.repoUrl ?? 'https://github.com/stablyai/orca', environmentId: 'env' }, list, translate, - { installing, install: noop, runSetup: noop, dismiss: noop, recheckFetch: noop, checkingFetch } + { + push: { + access: options.access ?? 'install', + installing: options.installing ?? false, + install: noop, + copyRequest: noop, + recheck: noop + }, + runSetup: noop, + dismiss: noop, + recheckFetch: noop, + checkingFetch: options.checkingFetch ?? false + } ) } @@ -70,10 +117,23 @@ function cards(views: readonly CloudToastView[]) { /** The push-access case Neil hit: public repo, App installed only on his personal account. */ export const pushOnly = cards([notices([PUSH])]) -export const pushWaitingForGitHub = cards([notices([PUSH], true)]) +export const pushWaitingForGitHub = cards([notices([PUSH], { installing: true })]) +/** ENV-NOTICE-ACTIONS: someone else's public repository: no Install, a request to copy for an owner. */ +export const pushNotInstallable = cards([ + notices([PUSH], { + access: 'cantInstall', + repoUrl: 'https://github.com/octocat/Hello-World' + }) +]) +/** An owner was already asked to approve the App: copy a request to nudge them. */ +export const pushRequested = cards([notices([PUSH], { access: 'requested' })]) +/** Plain words, no button: a sandbox's disk can't be grown from Orca. */ +export const diskLow = cards([notices([DISK_LOW])]) +/** Plain words naming the tool and what needs it; Orca can't install it, so no button. */ +export const toolMissing = cards([notices([TOOL_MISSING])]) export const setupOnly = cards([notices([SETUP])]) /** One family (DS-q): the action-required prompt and the warning, same chrome. */ export const agentAndPush = cards([AGENT, notices([PUSH, SETUP])]) /** ENV-ORIGIN-TOAST: origin still unreachable after two confirming checks; Check again re-runs it. */ export const fetchFailed = cards([notices([FETCH])]) -export const fetchChecking = cards([notices([FETCH], false, true)]) +export const fetchChecking = cards([notices([FETCH], { checkingFetch: true })]) diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.test.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.test.tsx index acdc2136998..1ec240795ca 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.test.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.test.tsx @@ -1,18 +1,29 @@ // @vitest-environment happy-dom -import { cleanup, render } from '@testing-library/react' +import { act, cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' const { stack } = vi.hoisted(() => ({ stack: { show: vi.fn(), hide: vi.fn() } })) vi.mock('./cloud-toast-stack', () => ({ showCloudToast: stack.show, hideCloudToast: stack.hide })) const { recheck } = vi.hoisted(() => ({ recheck: vi.fn(async () => 'unreachable' as const) })) vi.mock('@/lib/orca-cloud/cloud-fetch-notice', () => ({ recheckCloudFetch: recheck })) +const { pushAccess, copyRequest } = vi.hoisted(() => ({ + pushAccess: vi.fn(async (): Promise => 'install'), + copyRequest: vi.fn(async () => true) +})) +vi.mock('@/lib/orca-cloud/cloud-push-access', () => ({ + fetchCloudPushAccess: pushAccess, + copyOwnerPushRequest: copyRequest +})) vi.mock('@/store', () => ({ useAppStore: (select: (state: unknown) => unknown) => select({ ephemeralVmRuntimesByWorkspaceId: { w1: { runtimeEnvironmentId: 'env-1' } } }) })) -import { CloudSetupNoticesDock, cloudSetupToastId } from './CloudSetupNotices' +import { CloudSetupNoticesDock, cloudSetupNoticeLine, cloudSetupToastId } from './CloudSetupNotices' +import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage' +import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access' +import { translate } from '@/i18n/i18n' import { dismissCloudSetupNotice, resetCloudSetupNotices, @@ -26,7 +37,7 @@ afterEach(() => { }) describe('CloudSetupNoticesDock (DS-o, DS-q)', () => { - it('is one warning in the shared cloud toast stack, and leaves with its workspace', () => { + it('is one warning in the shared cloud toast stack, and leaves with its workspace', async () => { setCloudSetupNotices({ environmentId: 'env-1', path: '/w', @@ -42,6 +53,8 @@ describe('CloudSetupNoticesDock (DS-o, DS-q)', () => { ] }) const view = render() + // The access answer arrives after the first render. + await act(async () => {}) const [id, shown] = stack.show.mock.calls.at(-1) ?? [] expect(id).toBe(cloudSetupToastId('env-1')) expect(shown.priority).toBe('warning') @@ -92,4 +105,120 @@ describe('CloudSetupNoticesDock (DS-o, DS-q)', () => { shown.lines[0].action.onClick() expect(recheck).toHaveBeenCalledWith('env-1', expect.any(Function)) }) + + it('an owner already asked: the line offers a request to copy for them, never Install', async () => { + pushAccess.mockResolvedValueOnce('requested') + setCloudSetupNotices({ + environmentId: 'env-1', + path: '/w', + repoId: 'r', + hostId: null, + repoUrl: 'https://github.com/acme/web', + notices: [PUSH] + }) + render() + await act(async () => {}) + const [, shown] = stack.show.mock.calls.at(-1) ?? [] + expect(shown.lines[0].action).toMatchObject({ label: 'Copy request for an owner' }) + await act(async () => shown.lines[0].action.onClick()) + expect(copyRequest).toHaveBeenCalledWith('https://github.com/acme/web') + const [, copied] = stack.show.mock.calls.at(-1) ?? [] + expect(copied.lines[0].action).toMatchObject({ label: 'Copied' }) + }) +}) + +const PUSH: CloudSetupNotice = { + id: 'git.push', + kind: 'push', + check: { id: 'git.push', category: 'git', status: 'fail', message: 'refused' } +} + +const copyRequestHandler = vi.fn() + +function line(notice: CloudSetupNotice, access: CloudPushAccess = 'install', repoUrl?: string) { + return cloudSetupNoticeLine( + notice, + { repoUrl: repoUrl ?? 'https://github.com/stablyai/orca' }, + translate, + { + push: { + access, + install: vi.fn(), + installing: false, + copyRequest: copyRequestHandler, + recheck: vi.fn() + }, + runSetup: vi.fn() + } + ) +} + +describe('cloudSetupNoticeLine (ENV-NOTICE-ACTIONS)', () => { + it('low disk: plain words and what to expect, with no button (the disk can’t be grown here)', () => { + const shown = line({ + id: 'disk.free', + kind: 'disk', + check: { + id: 'disk.free', + category: 'disk', + status: 'fail', + message: 'Only 0.8 GB free.', + code: 'diskVeryLow', + params: { free: '0.8' } + } + }) + expect(shown.text).toBe('Only 0.8 GB of disk space is free in this sandbox.') + expect(shown.description).toBe( + 'Installs and builds may fail. Delete caches or build output you don’t need.' + ) + expect(shown.action).toBeNull() + }) + + it('a missing tool: names it and what needs it, never the doctor’s label', () => { + const shown = line({ + id: 'tool.pnpm', + kind: 'tool', + check: { + id: 'tool.pnpm', + category: 'tools', + status: 'fail', + message: 'pnpm is not on PATH, but pnpm-lock.yaml needs it.', + code: 'toolMissing', + params: { command: 'pnpm', reason: 'pnpm-lock.yaml' } + } + }) + expect(shown.text).toBe('pnpm isn’t installed in this sandbox, but pnpm-lock.yaml needs it.') + expect(shown.description).toContain('setup script') + expect(shown.action).toBeNull() + // An older server without params: the id still names the tool. + const older = line({ + id: 'tool.cargo', + kind: 'tool', + check: { id: 'tool.cargo', category: 'tools', status: 'fail', message: 'cargo missing' } + }) + expect(older.text).toBe('cargo isn’t installed in this sandbox.') + }) + + it('push: Install only where installing is possible, and without a Copy link', () => { + const install = line(PUSH, 'install') + expect(install.action).toMatchObject({ label: 'Install on stablyai' }) + // No Copy link: the action type has no link field (App installs never get one). + + const blocked = line(PUSH, 'cantInstall', 'https://github.com/octocat/Hello-World') + expect(blocked.text).toBe('Orca Cloud can read octocat/Hello-World but can’t push to it.') + expect(blocked.description).toBe( + 'Pushing needs the Orca GitHub App on octocat. Ask an owner of octocat to install it.' + ) + // No Install: only an owner can. The same Copy request as the requested state. + expect(blocked.action).toMatchObject({ label: 'Copy request for an owner' }) + expect(blocked.action?.label).not.toContain('Install') + blocked.action?.run() + expect(copyRequestHandler).toHaveBeenCalledTimes(1) + + expect(line(PUSH, 'requested').action).toMatchObject({ label: 'Copy request for an owner' }) + expect(line(PUSH, 'granted').action).toMatchObject({ label: 'Check again' }) + for (const access of ['checking', 'unknown'] as const) { + expect(line(PUSH, access).action).toBeNull() + } + }) }) diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx index f3f8abbef3b..fb8af4a2703 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx @@ -1,31 +1,23 @@ import React, { useEffect, useRef, useState } from 'react' -import { GitPullRequestArrow, Loader2, Play, RefreshCw } from 'lucide-react' +import { Loader2, Play, RefreshCw } from 'lucide-react' import { useAppStore } from '@/store' import { useTranslate, type Translate } from '@/i18n/use-translate' -import { environmentCheckCopy } from '@/components/worktree-creation/environment-check-copy' -import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage' +import { + missingToolCommand, + type CloudSetupNotice +} from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage' import { dismissCloudSetupNotice, useCloudSetupNoticesSnapshot, visibleCloudSetupNotices, type CloudSetupNoticesRecord } from '@/lib/orca-cloud/cloud-setup-notices-store' -import { - openOrcaGitHubAppInstall, - recheckCloudPushAccess, - repoOwner, - repoSlug, - runCloudSetupScript -} from '@/lib/orca-cloud/cloud-setup-notice-actions' +import { repoSlug, runCloudSetupScript } from '@/lib/orca-cloud/cloud-setup-notice-actions' import { recheckCloudFetch } from '@/lib/orca-cloud/cloud-fetch-notice' import { runVmEnvironmentCheck } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check' import { hideCloudToast, showCloudToast, type CloudToastView } from './cloud-toast-stack' - -// After Install: GitHub's webhook usually lands within seconds, then the owner may take a while. -const FAST_POLL_MS = 2_000 -const FAST_POLL_WINDOW_MS = 3 * 60_000 -const SLOW_POLL_MS = 10_000 -const GIVE_UP_MS = 30 * 60_000 +import { cloudPushNoticeLine, type CloudPushLineHandlers } from './cloud-push-notice-line' +import { useCloudPushNotice } from './use-cloud-push-notice' export type CloudSetupNoticeLine = { text: string @@ -39,46 +31,22 @@ export type CloudSetupNoticeLine = { } | null } +export type CloudSetupNoticeHandlers = { + push: CloudPushLineHandlers + runSetup: () => void + recheckFetch?: () => void + checkingFetch?: boolean +} + export function cloudSetupNoticeLine( notice: CloudSetupNotice, record: Pick, t: Translate, - handlers: { - install: () => void - runSetup: () => void - installing: boolean - recheckFetch?: () => void - checkingFetch?: boolean - } + handlers: CloudSetupNoticeHandlers ): CloudSetupNoticeLine { switch (notice.kind) { case 'push': - if (record.repoUrl) { - const owner = repoOwner(record.repoUrl) - return { - text: t( - 'orcaCloud.setupNotices.pushNeedsApp', - 'Orca Cloud can read {{repo}} but can’t push yet. Install the Orca GitHub App on {{owner}} to push.', - { repo: repoSlug(record.repoUrl), owner } - ), - action: { - label: handlers.installing - ? t('orcaCloud.setupNotices.waitingForGitHub', 'Waiting for GitHub…') - : t('orcaCloud.setupNotices.installOn', 'Install on {{owner}}', { owner }), - icon: handlers.installing ? ( - - ) : ( - - ), - run: handlers.install, - pending: handlers.installing - } - } - } - return { - text: t('orcaCloud.setupNotices.pushRefused', 'Orca Cloud can’t push from this workspace.'), - action: null - } + return cloudPushNoticeLine(record.repoUrl, t, handlers.push) case 'fetch': // ENV-ORIGIN-TOAST: shown only after a confirming check also failed; Check again re-runs it. return { @@ -116,81 +84,49 @@ export function cloudSetupNoticeLine( run: handlers.runSetup } } - case 'check': - return { text: environmentCheckCopy(notice.check, t, true).message, action: null } - } -} - -/** Polls repo access after Install until the push works, GitHub refuses it, or it gives up. */ -function usePushRecheck( - environmentId: string | undefined, - hasPush: boolean -): { - installing: boolean - startInstall: (repoUrl: string) => void -} { - const [installStartedAt, setInstallStartedAt] = useState(null) - const [tick, setTick] = useState(0) - const busy = useRef(false) - - useEffect(() => { - if (!environmentId || !hasPush || installStartedAt === null) { - return - } - const elapsed = Date.now() - installStartedAt - if (elapsed > GIVE_UP_MS) { - setInstallStartedAt(null) - return - } - const timer = setTimeout( - () => { - if (busy.current) { - setTick((n) => n + 1) - return - } - busy.current = true - void recheckCloudPushAccess(environmentId) - .then((result) => { - if (result !== 'waiting') { - setInstallStartedAt(null) - } - }) - .finally(() => { - busy.current = false - setTick((n) => n + 1) - }) - }, - elapsed < FAST_POLL_WINDOW_MS ? FAST_POLL_MS : SLOW_POLL_MS - ) - return () => clearTimeout(timer) - }, [environmentId, hasPush, installStartedAt, tick]) - - // Installed somewhere else (the browser, another device): check again when Orca is focused. - useEffect(() => { - if (!environmentId || !hasPush) { - return - } - const onFocus = (): void => { - if (!busy.current) { - busy.current = true - void recheckCloudPushAccess(environmentId).finally(() => { - busy.current = false - }) + case 'tool': { + // Orca can't install it for the person; the words say where it goes. + const command = missingToolCommand(notice.check) + const reason = notice.check.params?.reason + return { + text: reason + ? t( + 'orcaCloud.setupNotices.toolMissing', + '{{command}} isn’t installed in this sandbox, but {{reason}} needs it.', + { command, reason } + ) + : t( + 'orcaCloud.setupNotices.toolMissingNoReason', + '{{command}} isn’t installed in this sandbox.', + { command } + ), + description: t( + 'orcaCloud.setupNotices.toolMissingMeaning', + 'Install it from a terminal here, or add it to your project’s setup script so new workspaces have it.' + ), + action: null } } - window.addEventListener('focus', onFocus) - return () => window.removeEventListener('focus', onFocus) - }, [environmentId, hasPush]) - - return { - installing: installStartedAt !== null, - startInstall: (repoUrl) => { - void openOrcaGitHubAppInstall(repoUrl).then((opened) => { - if (opened) { - setInstallStartedAt(Date.now()) - } - }) - } + case 'disk': + // A sandbox's disk can't be grown from Orca, so no button: only what to expect. + return { + text: t( + 'orcaCloud.setupNotices.diskLow', + 'Only {{free}} GB of disk space is free in this sandbox.', + { free: notice.check.params?.free ?? '' } + ), + description: + notice.check.code === 'diskVeryLow' + ? t( + 'orcaCloud.setupNotices.diskVeryLowMeaning', + 'Installs and builds may fail. Delete caches or build output you don’t need.' + ) + : t( + 'orcaCloud.setupNotices.diskLowMeaning', + 'Large dependency installs may run out of space.' + ), + action: null + } } } @@ -223,8 +159,7 @@ export function cloudSetupNoticesView( notices: readonly CloudSetupNotice[], t: Translate, handlers: { - installing: boolean - install: () => void + push: CloudPushLineHandlers runSetup: (noticeId: string) => void dismiss: (noticeId: string) => void recheckFetch?: () => void @@ -236,9 +171,8 @@ export function cloudSetupNoticesView( label: t('orcaCloud.setupNotices.label', 'Workspace setup'), lines: notices.map((notice) => { const line = cloudSetupNoticeLine(notice, record, t, { - install: handlers.install, + push: handlers.push, runSetup: () => handlers.runSetup(notice.id), - installing: handlers.installing, ...(handlers.recheckFetch ? { recheckFetch: handlers.recheckFetch } : {}), ...(handlers.checkingFetch ? { checkingFetch: true } : {}) }) @@ -276,8 +210,9 @@ export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }): const snapshot = useCloudSetupNoticesSnapshot() const record = environmentId ? snapshot.records[environmentId] : undefined const notices = visibleCloudSetupNotices(snapshot, environmentId) - const push = usePushRecheck( + const push = useCloudPushNotice( environmentId, + record?.repoUrl ?? null, notices.some((notice) => notice.kind === 'push') ) const fetch = useFetchRecheck(environmentId) @@ -296,8 +231,7 @@ export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }): showCloudToast( toastId, cloudSetupNoticesView(record, notices, t, { - installing: push.installing, - install: () => record.repoUrl && push.startInstall(record.repoUrl), + push, runSetup: (noticeId) => void runCloudSetupScript(workspaceId, record, noticeId), dismiss: (noticeId) => dismissCloudSetupNotice(record.environmentId, noticeId), recheckFetch: fetch.recheck, diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index 22695bdef73..11e46733bc3 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' +import { toastSharedWithTeam } from '@/components/cloud-workspaces/shared-with-team-toast' import { isCloudSyncAvailable, requestCloudSyncFocus } from '@/lib/orca-cloud/cloud-sync-store' import { ConfirmationDialogContext, @@ -178,14 +179,16 @@ async function setVisibility( ) return } - toast.success( - visibility === 'team' - ? t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') - : t( - 'auto.components.cloudWorkspaces.nowPrivate', - 'Now private. Teammates’ devices were disconnected.' - ) - ) + if (visibility === 'team') { + toastSharedWithTeam(t) + } else { + toast.success( + t( + 'auto.components.cloudWorkspaces.nowPrivate', + 'Now private. Teammates’ devices were disconnected.' + ) + ) + } reload() } diff --git a/src/renderer/src/components/orca-cloud/cloud-push-notice-line.tsx b/src/renderer/src/components/orca-cloud/cloud-push-notice-line.tsx new file mode 100644 index 00000000000..394877a1ae6 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/cloud-push-notice-line.tsx @@ -0,0 +1,113 @@ +// The "can't push" line (ENV-NOTICE-ACTIONS): Install only where installing can work, a request to +// copy for an owner once one was asked, Check again where the App already reaches the repository, +// and, where only an owner can install, a request to copy for them with no Install button. +import { Check, Copy, GitPullRequestArrow, Loader2, RefreshCw } from 'lucide-react' +import type { Translate } from '@/i18n/use-translate' +import { repoOwner, repoSlug } from '@/lib/orca-cloud/cloud-setup-notice-actions' +import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access' +import type { CloudSetupNoticeLine } from './CloudSetupNotices' + +export type CloudPushLineHandlers = { + access: CloudPushAccess + install: () => void + installing: boolean + copyRequest: () => void + copied?: boolean + recheck: () => void + rechecking?: boolean +} + +/** Copy a request for an owner (pushRequestMessage), reading "Copied" for a moment after. */ +function copyRequestAction( + h: CloudPushLineHandlers, + t: Translate +): NonNullable { + return { + label: h.copied + ? t('orcaCloud.setupNotices.copied', 'Copied') + : t('orcaCloud.setupNotices.copyRequest', 'Copy request for an owner'), + icon: h.copied ? : , + run: h.copyRequest + } +} + +export function cloudPushNoticeLine( + repoUrl: string | null, + t: Translate, + h: CloudPushLineHandlers +): CloudSetupNoticeLine { + if (!repoUrl) { + return { + text: t('orcaCloud.setupNotices.pushRefused', 'Orca Cloud can’t push from this workspace.'), + action: null + } + } + const repo = repoSlug(repoUrl) + const owner = repoOwner(repoUrl) + switch (h.access) { + case 'install': + return { + text: t( + 'orcaCloud.setupNotices.pushNeedsApp', + 'Orca Cloud can read {{repo}} but can’t push yet. Install the Orca GitHub App on {{owner}} to push.', + { repo, owner } + ), + // The App's install page, not a sign-in: no Copy link beside it (AUTH-ONLY-COPY-LINK). + action: { + label: h.installing + ? t('orcaCloud.setupNotices.waitingForGitHub', 'Waiting for GitHub…') + : t('orcaCloud.setupNotices.installOn', 'Install on {{owner}}', { owner }), + icon: h.installing ? : , + run: h.install, + pending: h.installing + } + } + case 'requested': + return { + text: t( + 'orcaCloud.setupNotices.pushRequested', + 'Orca Cloud can read {{repo}} but can’t push yet. An owner of {{owner}} has been asked to approve the Orca GitHub App.', + { repo, owner } + ), + action: copyRequestAction(h, t) + } + case 'cantInstall': + return { + text: t( + 'orcaCloud.setupNotices.pushCantInstall', + 'Orca Cloud can read {{repo}} but can’t push to it.', + { repo } + ), + description: t( + 'orcaCloud.setupNotices.pushCantInstallMeaning', + 'Pushing needs the Orca GitHub App on {{owner}}. Ask an owner of {{owner}} to install it.', + { owner } + ), + // No Install: only an owner can. The same request an owner would get once asked. + action: copyRequestAction(h, t) + } + case 'granted': + return { + text: t('orcaCloud.setupNotices.pushNotYet', 'Orca Cloud can’t push to {{repo}} yet.', { + repo + }), + // The App reaches it, so a fresh sandbox's credential may just have been late. + action: { + label: h.rechecking + ? t('orcaCloud.setupNotices.checking', 'Checking…') + : t('orcaCloud.setupNotices.checkAgain', 'Check again'), + icon: h.rechecking ? : , + run: h.recheck, + ...(h.rechecking ? { pending: true } : {}) + } + } + case 'checking': + case 'unknown': + return { + text: t('orcaCloud.setupNotices.pushNotYet', 'Orca Cloud can’t push to {{repo}} yet.', { + repo + }), + action: null + } + } +} diff --git a/src/renderer/src/components/orca-cloud/use-cloud-push-notice.ts b/src/renderer/src/components/orca-cloud/use-cloud-push-notice.ts new file mode 100644 index 00000000000..b75e767164a --- /dev/null +++ b/src/renderer/src/components/orca-cloud/use-cloud-push-notice.ts @@ -0,0 +1,180 @@ +// The "can't push" notice's state: what installing can do for this person (cloud-push-access), the +// re-checks after Install, Copy request's brief "Copied", and Check again where the App already +// reaches the repository. +import { useEffect, useRef, useState } from 'react' +import { + openOrcaGitHubAppInstall, + recheckCloudPushAccess +} from '@/lib/orca-cloud/cloud-setup-notice-actions' +import { + copyOwnerPushRequest, + fetchCloudPushAccess, + type CloudPushAccess +} from '@/lib/orca-cloud/cloud-push-access' + +// After Install: GitHub's webhook usually lands within seconds, then the owner may take a while. +const FAST_POLL_MS = 2_000 +const FAST_POLL_WINDOW_MS = 3 * 60_000 +const SLOW_POLL_MS = 10_000 +const GIVE_UP_MS = 30 * 60_000 +/** How long "Copied" stays on the request button. */ +export const COPIED_MS = 1_500 + +export type CloudPushNoticeState = { + access: CloudPushAccess + installing: boolean + install: () => void + copyRequest: () => void + copied: boolean + recheck: () => void + rechecking: boolean +} + +/** Polls repo access after Install until the push works, GitHub refuses it, or it gives up. */ +function usePushRecheck( + environmentId: string | undefined, + hasPush: boolean +): { installing: boolean; startInstall: (repoUrl: string) => void } { + const [installStartedAt, setInstallStartedAt] = useState(null) + const [tick, setTick] = useState(0) + const busy = useRef(false) + + useEffect(() => { + if (!environmentId || !hasPush || installStartedAt === null) { + return + } + const elapsed = Date.now() - installStartedAt + if (elapsed > GIVE_UP_MS) { + setInstallStartedAt(null) + return + } + const timer = setTimeout( + () => { + if (busy.current) { + setTick((n) => n + 1) + return + } + busy.current = true + void recheckCloudPushAccess(environmentId) + .then((result) => { + if (result !== 'waiting') { + setInstallStartedAt(null) + } + }) + .finally(() => { + busy.current = false + setTick((n) => n + 1) + }) + }, + elapsed < FAST_POLL_WINDOW_MS ? FAST_POLL_MS : SLOW_POLL_MS + ) + return () => clearTimeout(timer) + }, [environmentId, hasPush, installStartedAt, tick]) + + // Installed somewhere else (the browser, another device): check again when Orca is focused. + useEffect(() => { + if (!environmentId || !hasPush) { + return + } + const onFocus = (): void => { + if (!busy.current) { + busy.current = true + void recheckCloudPushAccess(environmentId).finally(() => { + busy.current = false + }) + } + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [environmentId, hasPush]) + + return { + installing: installStartedAt !== null, + startInstall: (repoUrl) => { + void openOrcaGitHubAppInstall(repoUrl).then((opened) => { + if (opened) { + setInstallStartedAt(Date.now()) + } + }) + } + } +} + +/** What the person can do about the refused push, asked again whenever Orca regains focus. */ +function usePushAccess(repoUrl: string | null, hasPush: boolean): CloudPushAccess { + const [answer, setAnswer] = useState<{ repoUrl: string; access: CloudPushAccess } | null>(null) + const [tick, setTick] = useState(0) + useEffect(() => { + if (!hasPush || !repoUrl) { + return + } + let cancelled = false + void fetchCloudPushAccess(repoUrl, { force: tick > 0 }).then((access) => { + if (!cancelled) { + setAnswer({ repoUrl, access }) + } + }) + return () => { + cancelled = true + } + }, [hasPush, repoUrl, tick]) + useEffect(() => { + if (!hasPush || !repoUrl) { + return + } + // An install finished in the browser, or an owner approved the request. + const onFocus = (): void => setTick((n) => n + 1) + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [hasPush, repoUrl]) + return answer && answer.repoUrl === repoUrl ? answer.access : 'checking' +} + +export function useCloudPushNotice( + environmentId: string | undefined, + repoUrl: string | null, + hasPush: boolean +): CloudPushNoticeState { + const access = usePushAccess(repoUrl, hasPush) + const push = usePushRecheck(environmentId, hasPush) + const [copied, setCopied] = useState(false) + const copiedTimer = useRef | undefined>(undefined) + useEffect(() => () => clearTimeout(copiedTimer.current), []) + const [rechecking, setRechecking] = useState(false) + const recheckBusy = useRef(false) + return { + access, + installing: push.installing, + install: () => { + if (repoUrl) { + push.startInstall(repoUrl) + } + }, + copyRequest: () => { + if (!repoUrl) { + return + } + void copyOwnerPushRequest(repoUrl).then((ok) => { + if (ok) { + setCopied(true) + clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), COPIED_MS) + } + }) + }, + copied, + recheck: () => { + if (!environmentId || recheckBusy.current) { + return + } + recheckBusy.current = true + setRechecking(true) + // A push that works now drops the line (recheckCloudPushAccess); a refusal keeps it. + void recheckCloudPushAccess(environmentId).finally(() => { + recheckBusy.current = false + setRechecking(false) + }) + }, + rechecking + } +} diff --git a/src/renderer/src/components/settings/accounts/orca/use-account-mfa.ts b/src/renderer/src/components/settings/accounts/orca/use-account-mfa.ts index 0168f4ec334..574d74104b5 100644 --- a/src/renderer/src/components/settings/accounts/orca/use-account-mfa.ts +++ b/src/renderer/src/components/settings/accounts/orca/use-account-mfa.ts @@ -8,8 +8,7 @@ export const ACCOUNT_MFA_WATCH_MAX_MS = 5 * 60_000 export type AccountMfaState = /** Not read yet, every read failed, or this deployment has no MFA status: the row is hidden. */ - | { kind: 'unknown' } - | ({ kind: 'known' } & OrcaAccountMfa) + { kind: 'unknown' } | ({ kind: 'known' } & OrcaAccountMfa) export type AccountMfa = { state: AccountMfaState diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx index ad5d0d8ea23..ab2291b3601 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx @@ -199,7 +199,7 @@ function list( initialMode={mode} initialSaved={options.saved} onSetUpAgent={noop} - description="Only you get these, in every Orca Cloud workspace you start." + description="Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." /> ) diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx index ca4475202fd..70d236a775e 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx @@ -201,8 +201,8 @@ export function CloudSecretsPane({ ) : null } description={t( - 'cloudSecrets.personal.description', - 'Only you get these, in every Orca Cloud workspace you start.' + 'cloudSecrets.personal.descriptionUnshared', + 'Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them.' )} emptyTitle={t('cloudSecrets.personal.emptyTitle', 'No personal secrets yet')} emptyDetail={t( diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts new file mode 100644 index 00000000000..b2bab1da297 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts @@ -0,0 +1,51 @@ +// GH-CREATE-NO-ROW: a create with no project here yet (a GitHub-only Orca Cloud create) has a +// pending row atop the list when grouped by project, as in every other grouping; a create with a +// project still sits atop its own project. +import { describe, expect, it } from 'vitest' +import { repo, worktree } from './worktree-list-groups-test-fixtures' +import { buildRows } from './build-rows.test-fixture' + +function rows(groupBy: 'repo' | 'none', pending: { creationId: string; repoId: string }[]) { + return buildRows( + groupBy, + [{ ...worktree, lastActivityAt: 1 }], + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + 'manual', + {}, + undefined, + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + pending + ) +} + +describe('pending row for a create with no project yet', () => { + it('by project: sits atop the list, before the first project header', () => { + const list = rows('repo', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + // No blank project header for it (it used to get one, keyed "repo:"). + expect(list.filter((row) => row.type === 'header')).toHaveLength(1) + }) + + it('by project: a create with a project still sits atop that project only', () => { + const list = rows('repo', [{ creationId: 'local-1', repoId: repo.id }]) + const header = list.findIndex((row) => row.type === 'header') + expect(header).toBeGreaterThanOrEqual(0) + expect(list.findIndex((row) => row.type === 'pending-creation')).toBeGreaterThan(header) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + }) + + it('ungrouped: unchanged, every pending row on top', () => { + const list = rows('none', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx new file mode 100644 index 00000000000..7765689b53e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +// A cloud create's billing refusal is a precondition, not an error: its sidebar status reads in +// muted text (the glyph carries the state); a real failure stays red. +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { PendingWorktreeRow } from './PendingWorktreeRow' + +afterEach(cleanup) + +function failed( + errorCode: string | undefined, + log: string, + error: string +): PendingWorktreeCreation { + return { + creationId: 'c', + phase: 'provisioning-vm', + status: 'error', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + error, + ...(errorCode ? { errorCode } : {}), + provisioningLog: log, + request: githubCreateRequest({ + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: 'Fix login', + agent: null + }) + } +} + +function statusLine(entry: PendingWorktreeCreation): HTMLElement { + useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] }) + render() + const line = screen.getByText('Fix login').nextElementSibling + if (!(line instanceof HTMLElement)) { + throw new Error('no status line') + } + return line +} + +describe('pending row status colour', () => { + it('billing refusal: muted', () => { + const line = statusLine( + failed( + 'cloudPlanRequired', + '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted', + 'Orca Cloud workspaces need a plan with cloud hours.' + ) + ) + expect(line.className).toContain('text-muted-foreground') + expect(line.className).not.toContain('text-destructive') + }) + + it('a real failure: red', () => { + const line = statusLine(failed(undefined, '[orca-cloud] provisioning', 'fetch failed')) + expect(line.className).toContain('text-destructive') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx index ccc90ad092d..7dff544c420 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx @@ -10,6 +10,10 @@ import { import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' import { vmBuildFailureSummary } from '@/components/worktree-creation/vm-build-failure-toast' +import { + classifyVmBuildFailure, + isBillingFailure +} from '@/components/worktree-creation/vm-build-failure' import type { Repo } from '../../../../../../shared/repo-types' function statusLabel( @@ -67,6 +71,11 @@ export function PendingWorktreeRow({ const isError = entry.status === 'error' const isOrcaCloud = entry.request.ephemeralVmRecipe?.provider === 'orca-cloud' + // A billing refusal is a precondition, not an error: muted text; the glyph carries the state. + const refused = + isError && + isOrcaCloud && + isBillingFailure(classifyVmBuildFailure({ entry, error: entry.error ?? null, cloud: true })) return (
{statusLabel(entry, repos, t)} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx new file mode 100644 index 00000000000..38ef8a2cc50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx @@ -0,0 +1,62 @@ +// GH-CREATE-NO-ROW: a GitHub-only Orca Cloud create has a sidebar row while it builds, atop the list +// (it has no project here yet), with the cloud glyph from the start; the paired workspace's rows +// replace it on hand-off. +import { getDefaultSettings } from '../../../../../../shared/constants' +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { PendingWorktreeRow } from './PendingWorktreeRow' + +const T0 = 1_700_000_000_000 + +function creation( + creationId: string, + patch: Partial = {} +): PendingWorktreeCreation { + return { + creationId, + phase: 'provisioning-vm', + status: 'creating', + startedAt: T0, + indeterminate: false, + loaderVisible: true, + provisioningLog: '[orca-cloud] provisioning\n[orca-cloud] plan-clone', + provisioningLogStamps: [T0, T0 + 400], + request: githubCreateRequest({ + repoUrl: 'https://github.com/nwparker/orca-sandbox-push-test', + fullName: 'nwparker/orca-sandbox-push-test', + prompt: 'Add a push test', + agent: 'claude', + composerRepoId: 'repo' + }), + ...patch + } +} + +const rows = { + building: creation('building'), + opening: creation('opening', { phase: 'creating' }), + failed: creation('failed', { + status: 'error', + error: 'Orca Cloud workspaces need a plan with cloud hours.', + errorCode: 'cloudPlanRequired', + provisioningLog: '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted' + }) +} + +/** The sidebar's top while three GitHub-only creates are in flight: building, opening, refused. */ +export const githubOnlyPendingRows = defineFixture({ + width: 320, + store: { + settings: getDefaultSettings('/home/fixture'), + pendingWorktreeCreations: rows, + activePendingCreationId: 'building' + }, + render: () => ( +
+ {Object.keys(rows).map((creationId) => ( + + ))} +
+ ) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 240a3abf934..0d5f2d7c818 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21370,6 +21370,8 @@ "perSeatMonthValue": "{{value0}}/mo", "seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.", "inviteSeatRule": "Each invite uses a seat once accepted.", + "enterpriseRowTitle": "Single sign-on, directory sync and the audit log", + "openEnterpriseSettings": "Open Enterprise settings", "mfaSso": "2FA via SSO" }, "manageBilling": "Manage billing", @@ -21713,7 +21715,8 @@ "errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", "errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", "errorTeamSandboxLimitNoSeat_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", - "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again." + "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", + "sharedPersonalSecretsNote": "Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are." }, "orcaCloud": { "QueuedPrompt": { @@ -24499,8 +24502,6 @@ }, "cloudWorkspaces": { "newCloudWorkspace": "New cloud workspace", - "emptyStartBody": "It runs in a sandbox, so your agents keep working even when this computer is off.", - "emptyFirstTitle": "Start your first cloud workspace", "picker": { "title": "Open cloud workspace", "description": "Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.", @@ -24516,8 +24517,6 @@ "inSidebarNote_other": "{{count}} more are already in your sidebar.", "emptySearchTitle": "Nothing matches your search", "emptySearchBody": "Try a title, repository, branch, owner or W-number.", - "emptyAllInSidebarTitle": "Your cloud workspaces are already in the sidebar", - "emptyAllInSidebarBody": "Workspaces teammates share, and ones you start on another computer, show up here.", "emptyRunningTitle": "None of yours are running", "emptyRunningBody": "Teammates’ running workspaces can count toward the limit too. Try again in a moment.", "emptyTeammateTitle": "{{name}} has no cloud workspaces you can see", @@ -24529,7 +24528,11 @@ "sharedWorkspace": "shared workspace", "teammate": "teammate", "orcaCloud": "orca cloud" - } + }, + "emptyNothingElseTitle": "Nothing else to open", + "emptyNothingElseBody": "Every cloud workspace you can open is already in your sidebar.", + "emptyNoneTitle": "No cloud workspaces yet", + "emptyNoneBody": "Start one and it shows up in your sidebar." } }, "worktreeCreation": { @@ -28226,9 +28229,9 @@ }, "setUpAgent": "Set up an agent", "personal": { - "description": "Only you get these, in every Orca Cloud workspace you start.", "emptyTitle": "No personal secrets yet", - "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables." + "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.", + "descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." }, "org": { "description": "Everyone on your team gets these in their Orca Cloud workspaces.", @@ -28462,6 +28465,9 @@ "setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.", "notStartedRow": "{{agent}} hasn’t started" }, + "chatSend": { + "renewAbove": "Renew {{agent}} sign-in above to send" + }, "chatSignIn": { "renewAbove": "Your {{agent}} sign-in for Orca Cloud didn’t work. Renew it above, then send your message again." }, @@ -28512,7 +28518,21 @@ "fetchFailedNoRepo": "This workspace can’t reach its Git remote right now.", "checking": "Checking…", "checkAgain": "Check again", - "fetchFailedMeaning": "Pulls and pushes won’t work until it can." + "fetchFailedMeaning": "Pulls and pushes won’t work until it can.", + "toolMissing": "{{command}} isn’t installed in this sandbox, but {{reason}} needs it.", + "toolMissingNoReason": "{{command}} isn’t installed in this sandbox.", + "toolMissingMeaning": "Install it from a terminal here, or add it to your project’s setup script so new workspaces have it.", + "diskLow": "Only {{free}} GB of disk space is free in this sandbox.", + "diskVeryLowMeaning": "Installs and builds may fail. Delete caches or build output you don’t need.", + "diskLowMeaning": "Large dependency installs may run out of space.", + "pushRequested": "Orca Cloud can read {{repo}} but can’t push yet. An owner of {{owner}} has been asked to approve the Orca GitHub App.", + "copied": "Copied", + "copyRequest": "Copy request for an owner", + "pushCantInstall": "Orca Cloud can read {{repo}} but can’t push to it.", + "pushCantInstallMeaning": "Pushing needs the Orca GitHub App on {{owner}}. Ask an owner of {{owner}} to install it.", + "pushNotYet": "Orca Cloud can’t push to {{repo}} yet.", + "pushRequestMessage": "Could you give the Orca GitHub App access to {{repo}}? Orca Cloud needs it to push from workspaces on this repository.", + "pushRequestCopyFailed": "Couldn’t copy it." }, "toast": { "dismiss": "Dismiss" @@ -29064,6 +29084,10 @@ "nothingNeedsAttention": "Nothing needs attention." }, "agentSignInStrip": { + "renewInSettings": "Renew in Settings", + "finishRenewing": "Finish renewing", + "expiredHeldDetail": "Renew to start {{agent}}.", + "expiredHeldAnnounce": "Your {{agent}} sign-in expired. Renew to start {{agent}}.", "rowNeedsSignIn": "Needs sign-in", "rowNeedsSignInTooltip": "Set up once for Orca Cloud", "rowExpired": "Sign-in expired", @@ -29083,9 +29107,7 @@ "useMySignIn": "Use my {{agent}} sign-in", "signIn": "Sign in", "expired": "Your {{agent}} sign-in expired", - "expiredDetail": "Sign in once to fix every workspace.", "renew": "Renew", - "expiredAnnounce": "Your {{agent}} sign-in expired. Renew it once to fix every workspace.", "waiting": "Approve on {{site}}", "openAgain": "Open again", "waitingAnnounce": "Waiting for you to approve in your browser.", diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.test.ts index 3fc90b01ee6..53edf0cd0b2 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.test.ts @@ -79,16 +79,35 @@ describe('cloud agent launch hold', () => { expect(cloudAgentLaunchNeedsHold('claude')).toBe(true) }) - it('holds an expired launch until Renew fixes the account, then every held pane goes', async () => { + it('CHAT-RENEW-INPLACE (B): a renewal never launches; every held pane waits for its own Start', async () => { setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' })) - const first = holdCloudAgentLaunch({ paneKey: 'tab:a', agent: 'claude', prompt: 'fix it' }) - const second = holdCloudAgentLaunch({ paneKey: 'tab:b', agent: 'claude', prompt: null }) + let firstOutcome = 'pending' + let secondOutcome = 'pending' + void holdCloudAgentLaunch({ paneKey: 'tab:a', agent: 'claude', prompt: 'fix it' }).then( + (o) => (firstOutcome = o) + ) + void holdCloudAgentLaunch({ paneKey: 'tab:b', agent: 'claude', prompt: null }).then( + (o) => (secondOutcome = o) + ) await vi.waitFor(() => expect(readCloudAgentHold('tab:b')).not.toBeNull()) expect(readCloudAgentHold('tab:a')).toMatchObject({ prompt: 'fix it' }) setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) - await expect(first).resolves.toBe('proceed') - await expect(second).resolves.toBe('proceed') - expect(readCloudAgentHold('tab:a')).toBeNull() + for (let i = 0; i < 3; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + for (const key of ['tab:a', 'tab:b']) { + expect(readCloudAgentHold(key)).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'renewed' + }) + } + expect([firstOutcome, secondOutcome]).toEqual(['pending', 'pending']) + // Start in one tab launches that one; the other keeps waiting. + releaseCloudAgentHold('tab:a', 'proceed') + await vi.waitFor(() => expect(firstOutcome).toBe('proceed')) + expect(secondOutcome).toBe('pending') + expect(readCloudAgentHold('tab:b')).toMatchObject({ awaiting: 'renewed' }) + releaseCloudAgentHold('tab:b', 'cancel') }) it('cancel releases only that pane', async () => { diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts index a3aa2cfb59f..fbe3faed7c0 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts @@ -112,11 +112,7 @@ type HoldRequest = Omit & { restored?: boolean } -type Entry = CloudAgentHold & { - release: (outcome: 'proceed' | 'cancel') => void - /** Renewed or covered, it waits for the person's Start instead of starting (restored, or setup). */ - askFirst: boolean -} +type Entry = CloudAgentHold & { release: (outcome: 'proceed' | 'cancel') => void } const holds = new Map() /** Each live hold's outcome, so a second launch for the same key joins it instead of holding twice. */ @@ -298,8 +294,7 @@ async function placeCloudAgentHold({ ...hold, reason, ...(awaiting ? { awaiting } : {}), - release: finish, - askFirst: Boolean(restored) || reason !== 'accountExpired' + release: finish }) notify() }) @@ -315,14 +310,7 @@ function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void { const now = cloudAgentHoldReason(entry.agent, accounts) if (now !== null) { if (now !== entry.reason) { - // A removed sign-in becomes the one-time setup, which never starts unasked (PC-3b), even - // for a hold placed on an expired sign-in in this session. - holds.set(entry.paneKey, { - ...entry, - reason: now, - awaiting: undefined, - askFirst: entry.askFirst || now === 'notSetUp' - }) + holds.set(entry.paneKey, { ...entry, reason: now, awaiting: undefined }) notify() } return @@ -339,11 +327,9 @@ function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void { } return } - if (!entry.askFirst) { - // Renewed in this session: every pane held on recovery starts. - entry.release('proceed') - return - } + // CHAT-RENEW-INPLACE (team-lead, B): covered again, every held launch waits for its Start, in + // this session or restored, in every tab: "{{agent}} sign-in renewed · Start {{agent}}" after a + // renewal, "is set up · Start" after the one-time setup. Nothing launches unasked. const awaiting = entry.reason === 'notSetUp' ? 'setUp' : 'renewed' holds.set(entry.paneKey, { ...entry, reason: 'awaitingStart', awaiting }) notify() @@ -373,12 +359,7 @@ export async function startCloudAgentHold(paneKey: string): Promise<'started' | const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind if (now !== null || (coverage !== 'account' && coverage !== 'key')) { if (now !== null && now !== entry.reason) { - holds.set(paneKey, { - ...entry, - reason: now, - awaiting: undefined, - askFirst: entry.askFirst || now === 'notSetUp' - }) + holds.set(paneKey, { ...entry, reason: now, awaiting: undefined }) notify() } return 'held' diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist-match.ts b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist-match.ts new file mode 100644 index 00000000000..0dd790595d0 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist-match.ts @@ -0,0 +1,21 @@ +// Allowlist matching for the cloud glossary lint. Entries match by file + term + a snippet of the +// string, never by line number, so unrelated edits that shift lines don't make entries stale. + +export type GlossaryHit = { file: string; line: number; term: string; text: string } +export type AllowEntry = { path: string; term: string; match: string; reason: string } + +export function isAllowed(hit: GlossaryHit, entry: AllowEntry): boolean { + return ( + hit.file === entry.path && + hit.term === entry.term && + entry.match.length > 0 && + hit.text.includes(entry.match) + ) +} + +/** Entries that no longer match any hit, or that have no reason. */ +export function staleAllowEntries(hits: GlossaryHit[], allow: AllowEntry[]): AllowEntry[] { + return allow.filter( + (entry) => !entry.reason?.trim() || !hits.some((hit) => isAllowed(hit, entry)) + ) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json index 450bc63eb3b..0a777170fa2 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json @@ -1,128 +1,128 @@ [ { "path": "src/renderer/src/components/cloud-environment/EnvironmentForm.tsx", - "line": 127, "term": "container", + "match": "Reads Conductor and Dev Container setup from a local project.", "reason": "Dev Containers spec name" }, { "path": "src/renderer/src/components/settings/virtual-machines/runtimes/CloudVmSetupGuide.tsx", - "line": 20, "term": "VM", + "match": "Cloud VMs", "reason": "BYOC recipe VMs (experimental), not Orca Cloud" }, { "path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx", - "line": 372, "term": "VM", + "match": "Delete VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx", - "line": 138, "term": "VM", + "match": "The VM is running until you choose.", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx", - "line": 38, "term": "VM", + "match": "Delete this VM?", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx", - "line": 49, "term": "VM", + "match": "The VM and anything on it are destroyed, and the workspace isn’t created.", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx", - "line": 63, "term": "VM", + "match": "Delete VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts", - "line": 55, "term": "VM", + "match": "The VM is up, but some environment checks need attention", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts", - "line": 59, "term": "VM", + "match": "The VM is up, but {{count}} environment checks failed", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts", - "line": 62, "term": "VM", + "match": "The VM is up, but {{count}} environment check failed", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts", - "line": 15, "term": "VM", + "match": "The VM recipe failed", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts", - "line": 19, "term": "VM", + "match": "Your VM recipe stopped with an error before the VM was ready.", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts", - "line": 25, "term": "VM", + "match": "Couldn’t create the workspace on the VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts", - "line": 29, "term": "VM", + "match": "The VM started, but creating the workspace on it stopped with an error.", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts", - "line": 10, "term": "VM", + "match": "Provision VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts", - "line": 69, "term": "VM", + "match": "Building your VM workspace", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts", - "line": 74, "term": "VM", + "match": "Your VM workspace is ready", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts", - "line": 79, "term": "VM", + "match": "Your VM workspace needs attention", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts", - "line": 84, "term": "VM", + "match": "Couldn’t build your VM workspace", "reason": "recipe-VM branch; cloud branch says sandbox" }, { "path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts", - "line": 13, "term": "VM", + "match": "This workspace's VM is asleep", "reason": "match pattern for older builds' saved terminal errors; never rendered" }, { "path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts", - "line": 14, "term": "VM", + "match": "This workspace's VM could not wake up", "reason": "match pattern for older builds' saved terminal errors; never rendered" } ] diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-lint.test.ts b/src/renderer/src/lib/orca-cloud/cloud-glossary-lint.test.ts index 8078f45efef..acf3f226132 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-glossary-lint.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-lint.test.ts @@ -7,6 +7,12 @@ import ts from 'typescript-api' import { globSync } from 'glob' import { describe, expect, it } from 'vitest' import allowlist from './cloud-glossary-allowlist.json' +import { + isAllowed, + staleAllowEntries, + type AllowEntry, + type GlossaryHit +} from './cloud-glossary-allowlist-match' const ROOT = path.resolve(__dirname, '../../../../..') @@ -88,8 +94,7 @@ const TEXT_ATTRIBUTES = new Set([ ]) const TRANSLATE_CALLEES = new Set(['t', 'translate']) -type Hit = { file: string; line: number; term: string; text: string; rule: Rule } -type AllowEntry = { path: string; line: number; term: string; reason: string } +type Hit = GlossaryHit & { rule: Rule } function stringValue(node: ts.Node): string | null { if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { @@ -240,8 +245,6 @@ function findHits(): Hit[] { } const ALLOW: AllowEntry[] = allowlist -const allowKey = (e: { path: string; line: number; term: string }): string => - `${e.path}:${e.line}:${e.term}` describe('cloud glossary lint', () => { const hits = findHits() @@ -251,17 +254,16 @@ describe('cloud glossary lint', () => { }) it('every allowlist entry has a reason and still matches a string', () => { - const live = new Set(hits.map((h) => allowKey({ path: h.file, line: h.line, term: h.term }))) - const problems = ALLOW.filter((e) => !e.reason?.trim() || !live.has(allowKey(e))).map( - (e) => `stale or unexplained allowlist entry: ${allowKey(e)}` + const problems = staleAllowEntries(hits, ALLOW).map( + (e) => + `stale or unexplained allowlist entry: ${e.path} "${e.term}" match ${JSON.stringify(e.match)}` ) expect(problems).toEqual([]) }) it('cloud strings use glossary words', () => { - const allowed = new Set(ALLOW.map(allowKey)) const failures = hits - .filter((h) => !allowed.has(allowKey({ path: h.file, line: h.line, term: h.term }))) + .filter((h) => !ALLOW.some((e) => isAllowed(h, e))) .map( (h) => `cloud glossary: "${h.term}" at ${h.file}:${h.line}; use "${h.rule.use}" (glossary row: ${h.rule.row}). ` + @@ -270,3 +272,43 @@ describe('cloud glossary lint', () => { expect(failures).toEqual([]) }) }) + +describe('allowlist matching', () => { + const file = 'src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts' + const entry: AllowEntry = { + path: file, + term: 'VM', + match: 'Your VM workspace is ready', + reason: 'recipe-VM branch' + } + const hit = (over: Partial = {}): GlossaryHit => ({ + file, + line: 74, + term: 'VM', + text: 'Your VM workspace is ready', + ...over + }) + + it('still allows the same string after it moves to another line', () => { + expect(isAllowed(hit({ line: 74 }), entry)).toBe(true) + expect(isAllowed(hit({ line: 112 }), entry)).toBe(true) + }) + + it('does not allow a different string with the same term in the same file', () => { + expect(isAllowed(hit({ text: 'Delete this VM?' }), entry)).toBe(false) + }) + + it('does not allow the same snippet in a different file', () => { + expect(isAllowed(hit({ file: 'src/renderer/src/lib/other-cloud.ts' }), entry)).toBe(false) + }) + + it('reports an entry with no matching hit as stale', () => { + expect(staleAllowEntries([hit({ text: 'Delete this VM?' })], [entry])).toEqual([entry]) + expect(staleAllowEntries([hit()], [entry])).toEqual([]) + }) + + it('reports an entry with an empty reason', () => { + const unexplained = { ...entry, reason: ' ' } + expect(staleAllowEntries([hit()], [unexplained])).toEqual([unexplained]) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-held-launch-restore.test.ts b/src/renderer/src/lib/orca-cloud/cloud-held-launch-restore.test.ts index 3fb5e2eee4a..a90d42b945e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-held-launch-restore.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-held-launch-restore.test.ts @@ -291,7 +291,7 @@ describe('after a restart', () => { }) describe('in session (not restored)', () => { - it('in-session (not restored), an expired hold still starts on Renew as before', async () => { + it('CHAT-RENEW-INPLACE (B): in session, a renewed hold waits for Start: no create until Start', async () => { mocks.coverage = 'expired' setTabs([TAB]) const create = vi.fn(async (args: CreateWebRuntimeSessionTerminalOnHostArgs) => @@ -307,6 +307,13 @@ describe('in session (not restored)', () => { for (const listener of mocks.listeners) { listener() } + await flush() + expect(readCloudAgentHold(cloudTabHoldKey(TAB))).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'renewed' + }) + expect(create).not.toHaveBeenCalledWith(expect.objectContaining({ agent: 'claude' })) + releaseCloudAgentHold(cloudTabHoldKey(TAB), 'proceed') await launch expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'claude' })) }) @@ -332,7 +339,7 @@ describe('in session (not restored)', () => { expect(create).toHaveBeenCalledTimes(1) }) - it('a paused (kill switch) agent never starts the expired hold; a real renewal does', async () => { + it('a paused (kill switch) agent never starts the expired hold; a real renewal waits for Start (B)', async () => { mocks.coverage = 'expired' setTabs([TAB]) const create = vi.fn(async (args: CreateWebRuntimeSessionTerminalOnHostArgs) => @@ -354,6 +361,10 @@ describe('in session (not restored)', () => { for (const listener of mocks.listeners) { listener() } + await flush() + expect(readCloudAgentHold(cloudTabHoldKey(TAB))).toMatchObject({ awaiting: 'renewed' }) + expect(create).toHaveBeenCalledTimes(1) + releaseCloudAgentHold(cloudTabHoldKey(TAB), 'proceed') await launch expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'claude' })) }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-push-access.test.ts b/src/renderer/src/lib/orca-cloud/cloud-push-access.test.ts new file mode 100644 index 00000000000..8881f2d47c6 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-push-access.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { cloudPushAccess } from './cloud-push-access' + +const admin = { admin: true, ownerType: 'Organization' } as const +const orgMember = { admin: false, ownerType: 'Organization' } as const +const notOwner = { admin: false, ownerType: 'User' } as const + +describe('cloudPushAccess (ENV-NOTICE-ACTIONS)', () => { + it("someone else's public repository (octocat/Hello-World) can't be installed on", () => { + expect(cloudPushAccess('public', notOwner)).toBe('cantInstall') + expect(cloudPushAccess('public', orgMember)).toBe('cantInstall') + // gh couldn't tell: no Install unless it's known to work. + expect(cloudPushAccess('public', null)).toBe('cantInstall') + }) + + it('an admin of the owner can install, public or not', () => { + expect(cloudPushAccess('public', admin)).toBe('install') + expect(cloudPushAccess('public', { admin: true, ownerType: 'User' })).toBe('install') + expect(cloudPushAccess('missing', admin)).toBe('install') + }) + + it('missing: the control plane minted an install page; only a personal repo’s non-owner is stuck', () => { + // An org member's install becomes a request for an owner (D2). + expect(cloudPushAccess('missing', orgMember)).toBe('install') + expect(cloudPushAccess('missing', null)).toBe('install') + expect(cloudPushAccess('missing', notOwner)).toBe('cantInstall') + }) + + it('requested, granted, no answer yet, and no answer at all', () => { + expect(cloudPushAccess('requested', null)).toBe('requested') + expect(cloudPushAccess('granted', null)).toBe('granted') + expect(cloudPushAccess(undefined, null)).toBe('checking') + expect(cloudPushAccess(null, null)).toBe('unknown') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-push-access.ts b/src/renderer/src/lib/orca-cloud/cloud-push-access.ts new file mode 100644 index 00000000000..9b8cf994696 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-push-access.ts @@ -0,0 +1,84 @@ +// ENV-NOTICE-ACTIONS: what the "can't push" notice can offer, from what the control plane says about +// the Orca GitHub App's access (repo-access) and what the person's own gh login says about installing +// it. Install only shows where installing can work; a request already sent offers a copy for an owner; +// a public repository someone else owns (octocat/Hello-World) gets a request to copy, no Install. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { HostedGithubRepoAccessResponse } from '../../../../shared/hosted-sandbox/hosted-github-wire' +import type { GitHubInstallRights } from '../../../../shared/hosted-sandbox/cloud-workspace-api' +import { cloudWorkspaceApi } from './orca-cloud-workspace-store' +import { fetchOrcaCloudRepoAccess } from './use-orca-cloud-repo-access' +import { repoSlug } from './cloud-setup-notice-actions' + +/** + * `install`: Install on {owner}. `requested`: an owner was asked; copy a request for them. + * `cantInstall`: only the owner's admins can install, and this person isn't one. `granted`: the App + * reaches the repository, so the refusal is something else (Check again). `checking`: no answer yet. + * `unknown`: couldn't ask (offline, signed out). + */ +export type CloudPushAccess = + | 'install' + | 'requested' + | 'cantInstall' + | 'granted' + | 'checking' + | 'unknown' + +export function cloudPushAccess( + access: HostedGithubRepoAccessResponse['access'] | null | undefined, + rights: GitHubInstallRights | null +): CloudPushAccess { + switch (access) { + case undefined: + return 'checking' + case null: + return 'unknown' + case 'requested': + return 'requested' + case 'granted': + return 'granted' + case 'missing': + // The control plane minted an install page. An org member who isn't an admin still gets + // somewhere (GitHub turns the install into a request for an owner); a collaborator on someone's + // personal repository doesn't. + return rights && !rights.admin && rights.ownerType === 'User' ? 'cantInstall' : 'install' + case 'public': + // Readable by anyone, so readability says nothing about the person: only admin rights do. + return rights?.admin ? 'install' : 'cantInstall' + } +} + +/** The control plane's answer plus, where it decides the button, the person's install rights. */ +export async function fetchCloudPushAccess( + repoUrl: string, + options: { force?: boolean } = {} +): Promise { + const answer = await fetchOrcaCloudRepoAccess(repoUrl, options) + const access = answer?.access ?? null + if (access !== 'missing' && access !== 'public') { + return cloudPushAccess(access, null) + } + const rights = await cloudWorkspaceApi() + ?.gitHubInstallRights({ repoUrl }) + .then((result) => (result.ok ? result.value : null)) + .catch(() => null) + return cloudPushAccess(access, rights ?? null) +} + +/** A message for an owner of the repository's org, with the install page that preselects it. */ +export async function copyOwnerPushRequest(repoUrl: string): Promise { + // The install page the control plane minted for this repository (preselects it), if any. + const link = (await fetchOrcaCloudRepoAccess(repoUrl).catch(() => null))?.installUrl ?? null + const message = translate( + 'orcaCloud.setupNotices.pushRequestMessage', + 'Could you give the Orca GitHub App access to {{repo}}? Orca Cloud needs it to push from workspaces on this repository.', + { repo: repoSlug(repoUrl) } + ) + try { + await window.api.ui.writeClipboardText(link ? `${message}\n${link}` : message) + return true + } catch { + toast.error(translate('orcaCloud.setupNotices.pushRequestCopyFailed', 'Couldn’t copy it.')) + return false + } +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-notices-store.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-notices-store.ts index 6471b85b3a0..a6a4d803c23 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-notices-store.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-notices-store.ts @@ -64,7 +64,7 @@ export function getCloudSetupNoticesRecord( return snapshot.records[environmentId] } -/** Visible notices first by importance: push access, reaching GitHub, setup, then the rest. */ +/** Visible notices first by importance: push access, reaching GitHub, setup, tools, then disk. */ export function visibleCloudSetupNotices( state: Snapshot, environmentId: string | undefined @@ -73,7 +73,7 @@ export function visibleCloudSetupNotices( if (!record) { return [] } - const order = { push: 0, fetch: 1, setup: 2, check: 3 } as const + const order = { push: 0, fetch: 1, setup: 2, tool: 3, disk: 4 } as const return record.notices .filter((notice) => !state.dismissed.has(dismissalKey(record.environmentId, notice.id))) .slice() diff --git a/src/renderer/src/lib/orca-cloud/cloud-web-host-launch-hold.test.ts b/src/renderer/src/lib/orca-cloud/cloud-web-host-launch-hold.test.ts index e3912c0893a..81f6767e34e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-web-host-launch-hold.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-web-host-launch-hold.test.ts @@ -219,7 +219,7 @@ describe('createWithCloudAgentHold (the one hold for every Orca Cloud agent laun expect(readCloudAgentHold(key)).toBeNull() }) - it('expired: holds, then starts on Renew', async () => { + it('expired: holds; Renew turns it to "renewed · Start", and Start launches it (B)', async () => { setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' })) const create = hostCreate() const args = { ...CREATE_AGENT, prompt: 'fix the bug' } @@ -231,9 +231,14 @@ describe('createWithCloudAgentHold (the one hold for every Orca Cloud agent laun prompt: 'fix the bug' }) setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await flush() + // A renewal never launches by itself (team-lead, B): it waits for Start. + expect(readCloudAgentHold(key)).toMatchObject({ reason: 'awaitingStart', awaiting: 'renewed' }) + expect(create).toHaveBeenCalledOnce() + releaseCloudAgentHold(key, 'proceed') await expect(result).resolves.toBe(AGENT) - // Renew's auto-start: the original args, no focus taken (the tab may be in the background). - expect(create).toHaveBeenLastCalledWith(args) + // The person's Start: the original args, with its tab brought forward. + expect(create).toHaveBeenLastCalledWith({ ...args, activate: true }) }) it('cancel leaves the plain shell and never starts the agent', async () => { diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts index 911e85d51a9..9bc70e90829 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts @@ -50,6 +50,7 @@ function stub( gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), + gitHubInstallRights: vi.fn(unused), listGitHubRepositories: vi.fn(unused), createFromGitHub: vi.fn(unused), onWakeProgress: vi.fn(() => () => {}), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts index f6c889b15f4..3604bebb307 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts @@ -40,6 +40,7 @@ function stub( gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), + gitHubInstallRights: vi.fn(unused), listGitHubRepositories: vi.fn(unused), createFromGitHub: vi.fn(unused), onWakeProgress: vi.fn(() => () => {}), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index 8b440d8b65f..959e35a0e70 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -44,6 +44,7 @@ function stubApi(overrides: Partial = {}): OrcaCloudWorks connectGitHubUser: vi.fn(async () => ok(null)), gitHubUserConnectLink: vi.fn(async () => ok('https://github.com/login/oauth/authorize')), disconnectGitHubUser: vi.fn(async () => ok({ connected: false as const })), + gitHubInstallRights: vi.fn(async () => ok(null)), getRepoAccess: vi.fn(async () => ok({ access: 'granted' as const, owner: 'a', repo: 'https://github.com/a/b' }) ), diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts new file mode 100644 index 00000000000..216791fa2c3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +// GH-CREATE-CANCEL and GH-CREATE-NO-ROW for a GitHub-only Orca Cloud create: Cancel at any step +// leaves no workspace (the sandbox is deleted, which drops the pairing too), and a create that +// opens hands its pending row off to the paired server's rows, which stay hidden until then. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +type OpenOptions = { onHost?: (hostId: string) => void } +type OpenOutcome = { ok: true; worktreeId: string | null; hostId?: string } + +const mocks = vi.hoisted(() => ({ + open: vi.fn<(workspace: unknown, options?: OpenOptions) => Promise>(), + launch: vi.fn() +})) + +vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({ + openCloudWorkspace: mocks.open +})) +vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ + launchAgentInNewTabWithOutcome: mocks.launch +})) + +import { startCloudWorkspaceFromGitHub } from './start-cloud-workspace-from-github' +import { resetGitHubCreateKeysForTest } from './github-cloud-create-key' + +const createFromGitHub = vi.fn<(args: unknown) => Promise>() +const destroy = vi.fn(async () => ({ ok: true, value: undefined })) + +beforeEach(() => { + resetGitHubCreateKeysForTest() + useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null }) + createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } }) + mocks.open.mockResolvedValue({ ok: true, worktreeId: null }) + Object.assign(window, { + api: { + ...window.api, + orcaCloudWorkspace: { createFromGitHub }, + cloudWorkspaces: { + get: vi.fn(async (id: string) => ({ ok: true, value: { id } })), + destroy, + localWorkspaces: vi.fn(async () => []), + environmentBindings: vi.fn(async () => ({})) + }, + ephemeralVm: { + onProvisionEvent: () => () => {}, + cancelProvision: vi.fn(async () => ({ cancelled: true })), + // Cancel's cleanup looks for this create's runtime records; a GitHub-only create has none. + listRuntimes: vi.fn(async () => []) + } + } + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +const REQUEST = { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: '', + agent: null, + composerRepoId: 'repo-1' +} + +const creationId = (): string => + Object.keys(useAppStore.getState().pendingWorktreeCreations)[0] ?? '' +const cancel = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId()) + +describe('Cancel leaves no workspace (GH-CREATE-CANCEL)', () => { + it('cancelled after the sandbox started, before it opened: deleted, never opened', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: true, value: { sandboxId: 'sbx_1' } } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(mocks.open).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled while it opened: the sandbox is deleted (its pairing and row go with it)', async () => { + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + cancel() + return { ok: true, worktreeId: 'w1', hostId: 'runtime:p' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(destroy).toHaveBeenCalledWith('sbx_1') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('cancelled while the create was in flight: main reclaims it; nothing opens here', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: false, code: 'failed', message: 'Cancelled.' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(window.api.ephemeralVm.cancelProvision).toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() + }) +}) + +describe('pending row hand-off (GH-CREATE-NO-ROW)', () => { + it('records the paired server while opening, and the hand-off drops the pending row', async () => { + let pairingHostIdDuringOpen: string | undefined + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + pairingHostIdDuringOpen = + useAppStore.getState().pendingWorktreeCreations[creationId()]?.pairingHostId + return { ok: true, worktreeId: null } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(true) + expect(pairingHostIdDuringOpen).toBe('runtime:p') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + expect(destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts index 982b05ebc14..dd3fdd7000e 100644 --- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace' +import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity' import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' @@ -87,7 +88,12 @@ export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest): }) store.setActiveView('terminal') store.setSidebarOpen(true) - return runGitHubCloudCreate(creationId) + return runTracked(creationId) +} + +/** Cancel's "Deleting the sandbox…" waits for every step that may still own a sandbox. */ +function runTracked(creationId: string): Promise { + return trackOrcaCloudCreateStep(creationId, runGitHubCloudCreate(creationId)) } /** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */ @@ -114,7 +120,7 @@ export function retryGitHubCloudCreate(creationId: string): void { }) store.setActivePendingWorktreeCreation(creationId) store.setActiveView('terminal') - void runGitHubCloudCreate(creationId) + void runTracked(creationId) } function current(creationId: string) { @@ -186,13 +192,27 @@ async function runGitHubCloudCreate(creationId: string): Promise { return false } if (!current(creationId)) { - // Cancelled while it started: main stopped the create and deleted what it had made. - startedSandboxes.delete(creationId) + // GH-CREATE-CANCEL: cancelled after the sandbox started but before it opened. + await discardSandbox(creationId, sandboxId) return false } useAppStore.getState().updatePendingWorktreeCreation(creationId, { phase: 'creating' }) const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null) - const opened = workspace?.ok ? await openCloudWorkspace(workspace.value) : null + const opened = workspace?.ok + ? await openCloudWorkspace(workspace.value, { + // GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off. + onHost: (hostId) => + useAppStore + .getState() + .updatePendingWorktreeCreation(creationId, { pairingHostId: hostId }) + }) + : null + if (!current(creationId)) { + // GH-CREATE-CANCEL: cancelled while it opened. Opening can't be stopped half way, so the + // sandbox is deleted, which also drops this computer's pairing and its row. + await discardSandbox(creationId, sandboxId) + return false + } if (!opened?.ok) { // The sandbox exists from here on: Retry opens it and never creates it again. return fail( @@ -219,3 +239,14 @@ async function runGitHubCloudCreate(creationId: string): Promise { }) return true } + +/** A cancelled create's sandbox: deleted, so nothing keeps running (or billing) behind the cancel. */ +async function discardSandbox(creationId: string, sandboxId: string): Promise { + startedSandboxes.delete(creationId) + forgetGitHubCreateKey(creationId) + const result = await window.api.cloudWorkspaces.destroy(sandboxId).catch(() => null) + if (!result?.ok) { + console.warn('[orca-cloud] cancelled GitHub create: could not delete sandbox', sandboxId) + } + void refreshCloudWorktreeBindings().catch(() => undefined) +} diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts index 3e296c962c2..39cc60b4eff 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts @@ -125,3 +125,26 @@ describe('paired workspaces scoped by account (PAIRED-ACCOUNT-FILTER)', () => { expect(hostsFor('team-two', paired()).hiddenHostIds.has('runtime:gh')).toBe(false) }) }) + +describe('GH-CREATE-NO-ROW hand-off', () => { + it('hides the server a GitHub-only create is pairing to until its pending row hands off', () => { + const pairing = (pairingHostId?: string) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only these fields. + ({ + runtimeEnvironments: [{ id: 'p', name: 'web (cloud)', orcaCloudPaired: true }], + orcaProfileAuthStatus: { cloud: { userId: 'u1' } }, + pendingWorktreeCreations: { + gh: { + creationId: 'gh', + request: { githubSource: { fullName: 'acme/web' } }, + ...(pairingHostId ? { pairingHostId } : {}) + } + } + }) as unknown as Pick + const hidden = (state: Pick) => [ + ...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds + ] + expect(hidden(pairing())).toEqual([]) + expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p']) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts index ac77b801dd3..538b4601197 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts @@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime import type { Repo } from '../../../../shared/repo-types' type CloudScopeState = Pick & - Partial> + Partial< + Pick< + AppState, + 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId' | 'pendingWorktreeCreations' + > + > /** * Every Orca Cloud server this desktop runs, `+host` when the active team owns it and `-host` @@ -30,7 +35,12 @@ type CloudScopeState = Pick & export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const active = orcaCloudAccountId(state.orcaProfileAuthStatus?.cloud) const owners = state.cloudAccountIdByEnvironmentId ?? {} - return (state.runtimeEnvironments ?? []) + // GH-CREATE-NO-ROW: a GitHub-only create's pending row stands for its server's rows until the + // hand-off removes it; both change in one store update, so the row never shows twice. + const handingOff = Object.values(state.pendingWorktreeCreations ?? {}).flatMap((entry) => + entry.request.githubSource && entry.pairingHostId ? [`-${entry.pairingHostId}`] : [] + ) + const scoped = (state.runtimeEnvironments ?? []) .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) .flatMap((environment) => { const hostId = toRuntimeExecutionHostId(environment.id) @@ -41,7 +51,7 @@ export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const inTeam = active !== null && owners[environment.id] === active return [`${inTeam ? '+' : '-'}${hostId}`] }) - .join('\n') + return [...scoped, ...handingOff].join('\n') } export function parseOrcaCloudHostScopeKey(key: string): { diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index 6b8a9f043b4..d7b4b35f343 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -195,6 +195,8 @@ export type PendingWorktreeCreation = { resumedAt?: number /** VM creates: the provisioned VM's runtime environment, known once it answers. */ vmEnvironmentId?: string + /** GitHub-only Orca Cloud create: the server it is pairing to; its rows wait for the hand-off. */ + pairingHostId?: string // Local git checkout meter; absent when git emits none or creation runs over SSH/runtime. checkoutProgress?: WorktreeCheckoutProgress /** Phases entered so far with their start times, so the chat checklist can time each step. */ diff --git a/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.test.ts b/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.test.ts index 1123cdbf09a..d1c31767ad6 100644 --- a/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.test.ts +++ b/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it } from 'vitest' import type { EnvironmentDoctorCheck } from '../../../../shared/environment-doctor-types' -import { triageCloudEnvironmentChecks } from './ephemeral-vm-environment-check-triage' +import { + missingToolCommand, + triageCloudEnvironmentChecks +} from './ephemeral-vm-environment-check-triage' const c = ( id: string, status: EnvironmentDoctorCheck['status'], category: EnvironmentDoctorCheck['category'] = 'tools' ): EnvironmentDoctorCheck => ({ id, category, status, message: id }) +const disk = (code: 'diskLow' | 'diskVeryLow'): EnvironmentDoctorCheck => ({ + ...c('disk.free', code === 'diskLow' ? 'warn' : 'fail', 'disk'), + code, + params: { free: '0.8' } +}) +const kinds = (checks: EnvironmentDoctorCheck[]): [string, string][] => + triageCloudEnvironmentChecks(checks, { setupRunsOnCreate: true }).notices.map((notice) => [ + notice.id, + notice.kind + ]) describe('triageCloudEnvironmentChecks', () => { it('stops only on a sandbox nobody can work in', () => { @@ -41,7 +54,7 @@ describe('triageCloudEnvironmentChecks', () => { c('setup.run', 'warn', 'setup'), c('setup.script', 'warn', 'setup'), c('git.remote', 'warn', 'git'), - c('disk.free', 'warn', 'disk') + disk('diskLow') ], { setupRunsOnCreate: false } ) @@ -49,7 +62,7 @@ describe('triageCloudEnvironmentChecks', () => { expect(notices.map((notice) => [notice.id, notice.kind])).toEqual([ ['git.push', 'push'], ['setup.run', 'setup'], - ['disk.free', 'check'] + ['disk.free', 'disk'] ]) }) @@ -63,13 +76,45 @@ describe('triageCloudEnvironmentChecks', () => { it('a failed reach of origin is its own notice, never a bare check line (ENV-ORIGIN-TOAST)', () => { const { fatal, notices } = triageCloudEnvironmentChecks( - [c('git.fetch', 'fail', 'git'), c('disk.free', 'warn', 'disk')], + [c('git.fetch', 'fail', 'git'), disk('diskLow')], { setupRunsOnCreate: true } ) expect(fatal).toEqual([]) expect(notices.map((notice) => [notice.id, notice.kind])).toEqual([ ['git.fetch', 'fetch'], - ['disk.free', 'check'] + ['disk.free', 'disk'] ]) }) + + it('gives low disk and a missing tool their own kinds (ENV-NOTICE-ACTIONS)', () => { + expect( + kinds([ + disk('diskVeryLow'), + { + ...c('tool.pnpm', 'fail'), + code: 'toolMissing', + params: { command: 'pnpm', reason: 'pnpm-lock.yaml' } + }, + // An older server: no code or params, the id still names the tool. + c('tool.cargo', 'fail') + ]) + ).toEqual([ + ['disk.free', 'disk'], + ['tool.pnpm', 'tool'], + ['tool.cargo', 'tool'] + ]) + expect(missingToolCommand(c('tool.cargo', 'fail'))).toBe('cargo') + }) + + it('hides what nobody can act on, and any check it has no words for, never a bare label', () => { + expect( + kinds([ + // "Could not read free disk space." + c('disk.free', 'warn', 'disk'), + // Checks from a newer doctor this desktop doesn't know. + c('network.dns', 'fail', 'workspace'), + c('cache.stale', 'warn', 'setup') + ]) + ).toEqual([]) + }) }) diff --git a/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.ts b/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.ts index 3a5e800bdfb..7ba4c574e08 100644 --- a/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.ts +++ b/src/renderer/src/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage.ts @@ -14,7 +14,12 @@ const FATAL_CHECK_IDS: ReadonlySet = new Set([ 'tool.git' ]) -export type CloudSetupNoticeKind = 'push' | 'fetch' | 'setup' | 'check' +/** + * Each kind has its own plain words (CloudSetupNotices). `disk`: little free space; `tool`: a CLI the + * repository needs isn't installed. A check with no kind of its own never shows as the doctor's + * bare label (ENV-NOTICE-ACTIONS). + */ +export type CloudSetupNoticeKind = 'push' | 'fetch' | 'setup' | 'disk' | 'tool' /** One problem the opened workspace shows as a single dismissible line. */ export type CloudSetupNotice = { @@ -82,7 +87,23 @@ function noticeKind( case 'setup.script': case 'git.remote': return null + case 'disk.free': + // "Couldn't read free disk space" (no code) says nothing anyone can act on. + return (check.code === 'diskLow' || check.code === 'diskVeryLow') && check.params?.free + ? 'disk' + : null default: - return 'check' + // tool.git is fatal above; every other tool. is one the repository's files need. + if (check.id.startsWith('tool.') && check.id.length > 'tool.'.length) { + return 'tool' + } + // A check this desktop doesn't know yet (a newer doctor): hidden rather than shown as a bare + // English label with nothing to do. The create still goes ahead, as it did before. + return null } } + +/** The CLI a `tool` notice names: the doctor's param, else the id's suffix (older servers). */ +export function missingToolCommand(check: EnvironmentDoctorCheck): string { + return check.params?.command || check.id.slice('tool.'.length) +} diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 177eb7bf2f6..3ac855a0cf8 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -149,13 +149,7 @@ export function createRemovePendingWorktreeCreation( .catch(() => undefined) } const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get) - // A GitHub-only create past its start is opening a sandbox that stays; nothing is deleted. - const opening = Boolean(removedEntry.request.githubSource) && removedEntry.phase === 'creating' - if ( - removedEntry.status === 'creating' && - isOrcaCloudCreateRequest(removedEntry.request) && - !opening - ) { + if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) { beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], { runtimeId: removedEntry.request.ephemeralVmRuntimeId, createStartedAt: removedEntry.startedAt, diff --git a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts index f55349c25be..8fc823291c3 100644 --- a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts +++ b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts @@ -186,6 +186,7 @@ export type WorktreeSlice = { pausedMs?: number resumedAt?: number vmEnvironmentId?: string + pairingHostId?: string checkoutProgress?: PendingWorktreeCreation['checkoutProgress'] worktreeId?: string heldChatMessage?: string diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index 5578ec63c7a..8c61e6f6a57 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -58,6 +58,12 @@ export type CloudOomEvent = { at: number } +/** + * The person's own standing on a GitHub repository: admin on it (an owner of a personal account, + * or an org owner or repository admin), and whether its owner is a user or an organization. + */ +export type GitHubInstallRights = { admin: boolean; ownerType: 'User' | 'Organization' } + /** The status panel's read plus what the desktop needs from the sandbox view alongside it. */ export type CloudWorkspaceStatus = HostedWorkspaceStatus & { /** GIT-SETUP: commits and pushes are refused in the VM until a boot installs the identities. */ @@ -110,6 +116,13 @@ export type OrcaCloudWorkspaceApi = { repoUrl: string setupSessionId?: string }) => Promise> + /** + * Whether the person can install the Orca GitHub App on this repository's owner, read with their + * own GitHub CLI login; null when gh can't tell (not installed, signed out, slow). + */ + gitHubInstallRights: (args: { + repoUrl: string + }) => Promise> /** One page of the repositories the account's GitHub installations reach (cloud-start N12). */ listGitHubRepositories: (args: { cursor?: string | null @@ -148,6 +161,7 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = { gitHubUserConnectLink: 'orcaCloudWorkspace:gitHubUserConnectLink', disconnectGitHubUser: 'orcaCloudWorkspace:disconnectGitHubUser', getRepoAccess: 'orcaCloudWorkspace:getRepoAccess', + gitHubInstallRights: 'orcaCloudWorkspace:gitHubInstallRights', listGitHubRepositories: 'orcaCloudWorkspace:listGitHubRepositories', createFromGitHub: 'orcaCloudWorkspace:createFromGitHub', wakeProgress: 'orcaCloudWorkspace:wakeProgress', diff --git a/src/shared/sidebar-list/sidebar-row-plan.ts b/src/shared/sidebar-list/sidebar-row-plan.ts index 68dbcda8e62..195a5d30651 100644 --- a/src/shared/sidebar-list/sidebar-row-plan.ts +++ b/src/shared/sidebar-list/sidebar-row-plan.ts @@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) { const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items) const render = (collapsedGroups: Set): Row[] => { const result: Row[] = [] - if (groupBy !== 'repo') { - for (const creation of pendingCreations) { + // By project, a create sits atop its own project; one with no project here yet (a GitHub-only + // Orca Cloud create, GH-CREATE-NO-ROW) sits atop the list, as in every other grouping. + for (const creation of pendingCreations) { + if (groupBy !== 'repo' || !repoMap.has(creation.repoId)) { result.push(buildPendingCreationRow(creation, repoMap)) } } diff --git a/src/shared/sidebar-list/worktree-grouping.ts b/src/shared/sidebar-list/worktree-grouping.ts index ad1d8dd639b..387aa316f96 100644 --- a/src/shared/sidebar-list/worktree-grouping.ts +++ b/src/shared/sidebar-list/worktree-grouping.ts @@ -71,7 +71,8 @@ export function buildOrderedGroups(args: { ([repoId, candidate]) => [repoId, candidate.repo] as const ) ], - pendingRepoIds: pendingByRepo.keys(), + // A create with no project here yet sits atop the list instead (GH-CREATE-NO-ROW). + pendingRepoIds: [...pendingByRepo.keys()].filter((repoId) => repoMap.has(repoId)), repoOrder, projectOrderBy })