From 747c389b680e02f762b524c96731ea427819ad5d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:51:16 -0800 Subject: [PATCH 01/17] feat(billing): features answer says whether the account pays for a plan (PAID-TEAM-CHOOSE-PLAN) POST /v1/internal/entitlements/features now also returns subscribed: the account has a subscription that is active, trialing, past_due or unpaid. Auth counts it as Orca Cloud access, so a paying team that is not on the per-account rollout is no longer told to choose a plan (and then refused at checkout as already subscribed). Read from the subscription row Stripe's webhooks keep, in every ENTITLEMENTS_MODE. Canceled, incomplete, expired and paused subscriptions, the no-card trial and no subscription are false. Co-Authored-By: Claude --- cloud/apps/billing/src/entitlements.ts | 2 +- cloud/apps/billing/src/features.ts | 20 +++++++++- cloud/apps/billing/src/index.ts | 10 +++-- cloud/apps/billing/test/features.test.ts | 49 ++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 9 deletions(-) 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 24cfc27063e..438e971fe0a 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 }) + } + }) +}) From 3adf9a3b043646c389fde904ab03ea1debeecd2b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:04:05 -0800 Subject: [PATCH 02/17] fix(cloud): SEC-5 a shared workspace gets none of its owner's personal secrets ownersForSandbox resolves no personal owner unless the row is private, so a workspace shared with the team gets team and repository secrets only (values and placeholders). The egress proxy's cached keys are keyed by visibility so sharing takes effect at once. Share toast and Personal tab say so. --- cloud/apps/sandbox-control/src/secrets.ts | 22 +- cloud/apps/sandbox-control/src/session-env.ts | 5 +- .../test/shared-workspace-secrets.test.ts | 216 ++++++++++++++++++ .../cloud-workspaces/CloudWorkspacesHome.tsx | 9 +- .../orca-cloud/CloudWorkspaceMenuItems.tsx | 25 +- .../CloudSecretsPane.fixture.tsx | 2 +- .../cloud-secrets/CloudSecretsPane.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 6 +- 8 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 88f0b5d1fc4..1f7713324ad 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -165,12 +165,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 } } @@ -329,16 +335,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 92d2a15c485..7f7bb7a8c36 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -27,7 +27,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 @@ -43,7 +43,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/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index fb899cecf65..5533b809edb 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -221,8 +221,15 @@ export function CloudWorkspacesHome({ void refreshCloudMessageSenders().catch(() => undefined) } if (change.visibility === 'team') { + // SEC-5: the owner's personal secrets stop being injected once teammates can open it. toast.success( - t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') + 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.' + ) + } ) } else if (change.visibility === 'private') { toast.success( diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index 22695bdef73..ab4fc3cb34c 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx @@ -178,14 +178,25 @@ 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') { + // SEC-5: the owner's personal secrets stop being injected once teammates can open it. + 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.' ) - ) + } + ) + } else { + toast.success( + t( + 'auto.components.cloudWorkspaces.nowPrivate', + 'Now private. Teammates’ devices were disconnected.' + ) + ) + } reload() } 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b396585c676..aa8947d3d48 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21606,7 +21606,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": { @@ -28032,7 +28033,8 @@ "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.", From 10a6999ef7a98f9eedd44255aef58e1401f78535 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:16:40 -0800 Subject: [PATCH 03/17] fix(cloud): SEC-5 share toast through one helper; drop the replaced private catalog key --- .../cloud-workspaces/CloudWorkspacesHome.tsx | 12 ++---------- .../cloud-workspaces/shared-with-team-toast.ts | 15 +++++++++++++++ .../orca-cloud/CloudWorkspaceMenuItems.tsx | 12 ++---------- src/renderer/src/i18n/locales/en.json | 1 - 4 files changed, 19 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index 5533b809edb..094144d9230 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -32,6 +32,7 @@ import { useCloudReadPanel } from './use-cloud-read-panel' import type { MemberDirectory } from './cloud-workspace-format' import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender' import { describeError } from './cloud-workspace-errors' +import { toastSharedWithTeam } from './shared-with-team-toast' import { focusCloudChat } from './cloud-chat-focus' import * as homeArchive from './cloud-workspace-home-archive' import { focusedKind, frontCloudChat } from './cloud-workspace-links' @@ -221,16 +222,7 @@ export function CloudWorkspacesHome({ void refreshCloudMessageSenders().catch(() => undefined) } if (change.visibility === 'team') { - // SEC-5: the owner's personal secrets stop being injected once teammates can open it. - 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.' - ) - } - ) + toastSharedWithTeam(t) } else if (change.visibility === 'private') { toast.success( t( 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/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index ab4fc3cb34c..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, @@ -179,16 +180,7 @@ async function setVisibility( return } if (visibility === 'team') { - // SEC-5: the owner's personal secrets stop being injected once teammates can open it. - 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.' - ) - } - ) + toastSharedWithTeam(t) } else { toast.success( t( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c44781927fa..2c9dbbad384 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28032,7 +28032,6 @@ }, "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.", "descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." From 979c7d15ed220b09db014c785ff9ac16dec863ce Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:02:13 -0800 Subject: [PATCH 04/17] test(cloud-glossary): allowlist matches file + string snippet, not line number Allowlist entries keyed on path:line:term went stale whenever an unrelated edit shifted lines, breaking PRs. Entries now carry a 'match' snippet and allow a hit when file, term and snippet (hit.text.includes(match)) all match. Adds isAllowed/staleAllowEntries helpers and synthetic tests. --- .../cloud-glossary-allowlist-match.ts | 21 +++++++ .../orca-cloud/cloud-glossary-allowlist.json | 42 ++++++------- .../orca-cloud/cloud-glossary-lint.test.ts | 60 ++++++++++++++++--- 3 files changed, 93 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist-match.ts 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 e9a169b5dae..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": 412, "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]) + }) +}) From 7eccb72a9acd5d014ddd18d7acccfef0afce4a18 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 11:21:37 -0800 Subject: [PATCH 05/17] wip chat-renew-inplace: idle strip + send block --- .../CloudStructuredChatBanners.tsx | 7 +- .../cloud-chat-running-strip.test.tsx | 54 +++++++++++++++- .../use-cloud-chat-running-strip.ts | 64 ++++++++++++++----- .../host-access/NativeChatInstallSlot.tsx | 20 +++++- .../pane/actions/NativeChatPaneHeader.tsx | 1 + src/renderer/src/i18n/locales/en.json | 3 + 6 files changed, 126 insertions(+), 23 deletions(-) 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-chat-running-strip.test.tsx b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-running-strip.test.tsx index a53c70fc85f..45147d775d6 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' @@ -94,6 +94,7 @@ vi.stubGlobal( ) const { CloudStructuredChatBanners } = await import('./CloudStructuredChatBanners') +const { useCloudChatSendBlock } = await import('./use-cloud-chat-running-strip') const mount = () => render( @@ -139,3 +140,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/use-cloud-chat-running-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-chat-running-strip.ts index ef46a8abf49..9a5ab27a572 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 @@ -43,27 +47,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 +83,18 @@ 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, + entry: status ?? { agentType: agent, state: 'idle' }, accounts, held, now: orcaCloudAgentAccountsLoadedAt(), @@ -92,6 +102,12 @@ 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) if (!strip) { return null } @@ -106,3 +122,17 @@ export function useCloudChatRunningStrip(args: { } return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions } } + +/** + * 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/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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1bb5e0ec9a2..dc213e5b036 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28413,6 +28413,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." }, From f5e9547beed4132624f89b077ff9cb34e9489843 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:14:50 -0800 Subject: [PATCH 06/17] fix(cloud): CHAT-RENEW-INPLACE (B) a renewal never launches a held agent; every held launch waits for its own Start --- .../cloud-agent-launch-hold.test.ts | 31 +++++++++++++---- .../lib/orca-cloud/cloud-agent-launch-hold.ts | 33 ++++--------------- .../cloud-held-launch-restore.test.ts | 15 +++++++-- .../cloud-web-host-launch-hold.test.ts | 11 +++++-- 4 files changed, 53 insertions(+), 37 deletions(-) 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-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-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 () => { From e95d45c0e68fa147e56c8bfb4ede5a0951e5b53d Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:46:37 -0800 Subject: [PATCH 07/17] feat(cloud): CHAT-RENEW-INPLACE every expired strip renews in place (held, running terminal, chat); idle-chat strip and send block; fixtures --- .../CloudAgentSignInStrip.tsx | 13 ++ .../CloudRenewInPlace.fixture.tsx | 136 +++++++++++++++ .../CloudRunningStripPortal.test.tsx | 9 +- .../CloudRunningStripPortal.tsx | 26 ++- .../CloudStripSettingsLink.tsx | 34 ++++ .../cloud-agent-strip-model.test.ts | 4 +- .../cloud-agent-strip-model.ts | 11 +- .../cloud-chat-running-strip.test.tsx | 10 +- .../cloud-renew-in-place.test.ts | 153 +++++++++++++++++ .../cloud-renew-in-place.ts | 159 ++++++++++++++++++ .../cloud-setup-strip-actions.ts | 2 +- .../use-cloud-chat-running-strip.ts | 28 ++- .../use-cloud-held-strip.ts | 28 ++- src/renderer/src/i18n/locales/en.json | 4 + 14 files changed, 587 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudRenewInPlace.fixture.tsx create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx create mode 100644 src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.test.ts create mode 100644 src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts 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..e8bc3692d04 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx @@ -0,0 +1,34 @@ +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/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 45147d775d6..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 @@ -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: {}, @@ -118,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 }) }) 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..f6514d36286 --- /dev/null +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-renew-in-place.ts @@ -0,0 +1,159 @@ +// 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, + 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 9a5ab27a572..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 @@ -26,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 { @@ -94,7 +89,8 @@ export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStr } return cloudRunningStrip({ paneKey: args.statusKey, - entry: status ?? { agentType: agent, state: 'idle' }, + // 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(), @@ -108,11 +104,12 @@ export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStr 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, @@ -120,7 +117,20 @@ export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip // 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 } } /** 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dc213e5b036..7caf5e1b393 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29017,6 +29017,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", From 22090289d709655c22de739e8cff0ec4a3f439e7 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:30:49 -0800 Subject: [PATCH 08/17] chore(i18n): CHAT-RENEW-INPLACE drop the held strip's old expired copy keys --- src/renderer/src/i18n/locales/en.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7caf5e1b393..0a55f8e38ba 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29040,9 +29040,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.", From 353ad5b8df346bd146a742295ec523147aeaa2a9 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:36:33 -0800 Subject: [PATCH 09/17] fix(cloud): CHAT-RENEW-INPLACE "Renew in Settings" is underlined at rest (quiet-link rule) --- .../components/agent-sign-in-strip/CloudStripSettingsLink.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx index e8bc3692d04..39bce76b0dc 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudStripSettingsLink.tsx @@ -23,7 +23,8 @@ export function CloudStripSettingsLink({ data-strip-settings-link onClick={onOpen} className={cn( - 'shrink-0 cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline', + // The quiet-link rule: underlined at rest (muted), stronger on hover, a visible focus ring. + 'shrink-0 cursor-pointer text-xs text-muted-foreground underline decoration-muted-foreground/50 underline-offset-2 hover:text-foreground hover:decoration-foreground', 'rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring', ownLine && 'basis-full text-left' )} From 084b3d1e9dd2d0efc37ff8a659453baa580ff4fc Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:37:42 -0800 Subject: [PATCH 10/17] fix(cloud): CHAT-RENEW-INPLACE Open again keeps its Copy link during a renewal --- .../src/components/agent-sign-in-strip/cloud-renew-in-place.ts | 2 ++ 1 file changed, 2 insertions(+) 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 index f6514d36286..6934dc64204 100644 --- 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 @@ -116,6 +116,8 @@ export function cloudRenewInPlaceActions(args: { }, 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()) From bd8e7002cbf706ccb16e92c59bc9b645352bee25 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:31:26 -0800 Subject: [PATCH 11/17] fix(cloud-create): GitHub-only creates get a sidebar row and a real Cancel (GH-CREATE-NO-ROW, GH-CREATE-CANCEL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NO-ROW: a create with no project here yet (GitHub-only Orca Cloud) now has its pending row atop the list when grouped by project too (it got a blank 'repo:' section before). While it opens, openCloudWorkspace reports the server it pairs to (onHost); the sidebar hides that server's rows until the hand-off removes the pending row, both in one store update, so the workspace never shows twice. CANCEL: createSandboxWithProgress (owned and GitHub-only creates) deletes what a cancel leaves: cancelled in flight, it replays the create by its Idempotency-Key and deletes the sandbox the control plane hands back (reclaimCancelledCreate, retrying while the first is still in progress); cancelled just as it answered, it deletes it. A cancelled clone already deleted itself. The GitHub runner deletes the sandbox when Cancel lands before or during Open workspace (which also drops this computer's pairing), and cancel's 'Deleting the sandbox…' waits for it. Copy link moves to cloud-workspace-share-link.ts (re-exported) to keep open-cloud-workspace.ts within max-lines. --- .../cloud-create-from-github.ts | 5 - .../hosted-sandbox-create-reclaim.test.ts | 105 ++++++++++++++++ .../hosted-sandbox-create-reclaim.ts | 64 ++++++++++ .../hosted-sandbox/hosted-sandbox-driver.ts | 38 ++++-- .../cloud-workspace-share-link.ts | 68 ++++++++++ .../cloud-workspaces/open-cloud-workspace.ts | 105 +++++----------- .../grouping/projectless-pending-row.test.ts | 51 ++++++++ .../rows/PendingWorktreeRowGitHub.fixture.tsx | 62 ++++++++++ ...cloud-workspace-from-github.cancel.test.ts | 117 ++++++++++++++++++ .../start-cloud-workspace-from-github.ts | 41 +++++- .../use-sidebar-hidden-checkouts.test.ts | 23 ++++ .../use-sidebar-hidden-checkouts.ts | 16 ++- .../creation/pending-worktree-creation.ts | 2 + .../create/pending-worktree-creation.ts | 8 +- .../worktrees/worktree-slice-contract.ts | 1 + src/shared/sidebar-list/sidebar-row-plan.ts | 6 +- src/shared/sidebar-list/worktree-grouping.ts | 3 +- 17 files changed, 608 insertions(+), 107 deletions(-) create mode 100644 src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts create mode 100644 src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx create mode 100644 src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts 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/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/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/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/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/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/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/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 }) From 52b838fb4252d05b7eaef059b90df5e89fe3f51b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:34:27 -0800 Subject: [PATCH 12/17] fix(sidebar): a cloud create's billing refusal reads in muted text (design-squad #623) --- .../rows/PendingWorktreeRow.refusal.test.tsx | 60 +++++++++++++++++++ .../worktree-list/rows/PendingWorktreeRow.tsx | 11 +++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx 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..438da0aac4a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx @@ -0,0 +1,60 @@ +// @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() + return screen.getByText('Fix login').nextElementSibling as HTMLElement +} + +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)} From 849cf33d1c325f1b3f619d83628bb0a114d2e4d9 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:45:15 -0800 Subject: [PATCH 13/17] test(sidebar): type guard instead of a cast in the refusal colour test --- .../worktree-list/rows/PendingWorktreeRow.refusal.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 438da0aac4a..7765689b53e 100644 --- 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 @@ -37,7 +37,11 @@ function failed( function statusLine(entry: PendingWorktreeCreation): HTMLElement { useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] }) render() - return screen.getByText('Fix login').nextElementSibling as HTMLElement + const line = screen.getByText('Fix login').nextElementSibling + if (!(line instanceof HTMLElement)) { + throw new Error('no status line') + } + return line } describe('pending row status colour', () => { From c801f4018643f6faac566153593303a91f0dbf38 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:40:39 -0800 Subject: [PATCH 14/17] =?UTF-8?q?fix(team):=20TEAM-MODAL-CENTER=20and=20TE?= =?UTF-8?q?AM-ENTERPRISE-ROW;=20centre=20the=20Team=20dialog,=20quiet=20ro?= =?UTF-8?q?w=20to=20Settings=20=E2=80=BA=20Enterprise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../billing/team/TeamDialogContent.test.tsx | 33 ++++++++++++++++ .../billing/team/TeamDialogHost.tsx | 16 +++++++- .../billing/team/TeamPageView.fixture.tsx | 18 ++++++++- .../billing/team/TeamPageView.test.tsx | 39 ++++++++++++++++++- .../components/billing/team/TeamPageView.tsx | 35 ++++++++++++++++- src/renderer/src/i18n/locales/en.json | 4 +- 6 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/billing/team/TeamDialogContent.test.tsx 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 3b2b8efccc1..0621bb278a9 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -231,6 +231,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 ? ( @@ -248,13 +255,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 1eb77834e17..1b1d49dc5b3 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..d88e8e3479f 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,38 @@ 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, SCIM and 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, SCIM and audit log')).toBeNull() +}) diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index ccf26d7b5a2..36af125a00d 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' @@ -55,6 +55,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 { @@ -108,6 +110,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)) @@ -275,8 +281,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, SCIM and audit log' + )} +

+ +
+ ) : null} {props.dangerZone}
) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2645702e767..e423e448daf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21352,7 +21352,9 @@ "perSeat": "Per seat", "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." + "inviteSeatRule": "Each invite uses a seat once accepted.", + "enterpriseRowTitle": "Single sign-on, SCIM and audit log", + "openEnterpriseSettings": "Open Enterprise settings" }, "manageBilling": "Manage billing", "comparePlans": "Compare plans", From 596f0598bc9c96d0ce9d23e3b6b7e80210762708 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:44:30 -0800 Subject: [PATCH 15/17] fix(team): Enterprise row reads 'Single sign-on, directory sync and the audit log' --- .../src/components/billing/team/TeamPageView.test.tsx | 6 ++++-- src/renderer/src/components/billing/team/TeamPageView.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/billing/team/TeamPageView.test.tsx b/src/renderer/src/components/billing/team/TeamPageView.test.tsx index d88e8e3479f..37311a6df5e 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.test.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.test.tsx @@ -73,7 +73,9 @@ it('points owners and admins to Settings › Enterprise with one plain button', />, { wrapper: TooltipProvider } ) - const row = screen.getByRole('region', { name: 'Single sign-on, SCIM and audit log' }) + 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' })) @@ -94,5 +96,5 @@ it('hides the Enterprise row from plain members', () => { />, { wrapper: TooltipProvider } ) - expect(screen.queryByText('Single sign-on, SCIM and audit log')).toBeNull() + 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 36af125a00d..5616c9585d1 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -297,7 +297,7 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { {t( 'auto.components.billing.team.enterpriseRowTitle', - 'Single sign-on, SCIM and audit log' + 'Single sign-on, directory sync and the audit log' )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53b9a0f04d0..1ebfffab42d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -24484,8 +24484,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.", @@ -24501,8 +24499,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", @@ -24514,7 +24510,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": { From 2e36fa4d56e8ab1170cec0dec4ab5ff5867d8057 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:03:53 -0800 Subject: [PATCH 17/17] fix(cloud): ENV-NOTICE-ACTIONS setup notices get plain words and a real fix, or don't show; no Install where it can't install (#644) * feat(cloud): every setup notice line has plain words and a real action, or doesn't show (ENV-NOTICE-ACTIONS) - Doctor checks that reached the bare 'check' line now classify: low disk ('disk') and a missing repo tool ('tool') get their own plain words; disk.free without a code and any check this desktop doesn't know are hidden, never a bare label. - The push notice reads repo access plus the person's own gh rights on the repo (new gitHubInstallRights IPC): Install on {owner} only where installing can work, 'Copy request for an owner' once an owner was asked, Check again where the App already reaches the repo, plain words for someone else's public repo (octocat/Hello-World). Install no longer carries a Copy link. * copy(cloud): a push nobody here can install offers Copy request for an owner (design-squad, ENV-NOTICE-ACTIONS) * fix(cloud): ENV-NOTICE-ACTIONS rebase onto AUTH-ONLY-COPY-LINK: the owner request takes the install page from the repo-access answer --- .../hosted-sandbox/github-repo-ids.test.ts | 14 ++ src/main/hosted-sandbox/github-repo-ids.ts | 52 +++++ .../hosted-sandbox/hosted-account-ipc.test.ts | 27 +++ src/main/hosted-sandbox/hosted-account-ipc.ts | 24 ++- .../api/orca-cloud-workspace-bridge.ts | 1 + .../orca-cloud/CloudSetupNotices.fixture.tsx | 70 ++++++- .../orca-cloud/CloudSetupNotices.test.tsx | 135 +++++++++++- .../orca-cloud/CloudSetupNotices.tsx | 192 ++++++------------ .../orca-cloud/cloud-push-notice-line.tsx | 113 +++++++++++ .../orca-cloud/use-cloud-push-notice.ts | 180 ++++++++++++++++ src/renderer/src/i18n/locales/en.json | 16 +- .../lib/orca-cloud/cloud-push-access.test.ts | 35 ++++ .../src/lib/orca-cloud/cloud-push-access.ts | 84 ++++++++ .../orca-cloud/cloud-setup-notices-store.ts | 4 +- ...ca-cloud-workspace-store-git-setup.test.ts | 1 + .../orca-cloud-workspace-store-wake.test.ts | 1 + .../orca-cloud-workspace-store.test.ts | 1 + ...emeral-vm-environment-check-triage.test.ts | 55 ++++- .../ephemeral-vm-environment-check-triage.ts | 25 ++- .../hosted-sandbox/cloud-workspace-api.ts | 14 ++ 20 files changed, 895 insertions(+), 149 deletions(-) create mode 100644 src/main/hosted-sandbox/github-repo-ids.test.ts create mode 100644 src/renderer/src/components/orca-cloud/cloud-push-notice-line.tsx create mode 100644 src/renderer/src/components/orca-cloud/use-cloud-push-notice.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-push-access.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-push-access.ts 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/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/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/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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d4c07704955..32d6127f4ab 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28500,7 +28500,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" 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/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/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/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',