Merge remote-tracking branch 'private/cloud/integration' into cloud/mfa-enterprise-row

# Conflicts:
#	src/renderer/src/components/team-mfa/team-mfa-copy.ts
#	src/renderer/src/i18n/locales/en.json
This commit is contained in:
Neil
2026-11-13 01:06:53 -08:00
83 changed files with 2957 additions and 446 deletions
+1 -1
View File
@@ -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)
+19 -1
View File
@@ -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<Env, 'BILLING_DB' | 'ENTITLEMENTS_MO
}
return flags
}
/**
* Whether the account holds a paid subscription that is live (active, trialing) or in payment
* recovery (past_due, unpaid): a paying customer, whatever its hours or payment state, whose
* problems the entitlement itself reports. Canceled, incomplete and expired subscriptions, the
* no-card trial and no subscription at all are false. Independent of ENTITLEMENTS_MODE: it reads
* the subscription row Stripe's webhooks keep, as the legacy entitlement does.
*/
export async function hasPaidSubscription(db: D1Database, accountId: string): Promise<boolean> {
const subscription = await currentSubscription(db, accountId)
return subscription !== null && (LIVE_STATUSES.has(subscription.status) || PAYMENT_FAILED_STATUSES.has(subscription.status))
}
+7 -3
View File
@@ -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': {
+45 -4
View File
@@ -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<ReturnType<typeof createHarness>>, 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 })
}
})
})
+17 -5
View File
@@ -188,12 +188,18 @@ function repoOwner(accountId: string, repo: string): string {
return `${accountId}#${repo}`
}
/** The owners whose secrets a sandbox session receives. */
export function ownersForSandbox(row: Pick<SandboxRow, 'account_id' | 'user_id' | 'repo_url'>) {
/**
* 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<SandboxRow, 'account_id' | 'user_id' | 'repo_url' | 'visibility'>) {
const repo = normalizeRepo(row.repo_url)
return {
org: ORG_ACCOUNT.test(row.account_id) ? row.account_id : null,
personal: personalOwner(row.user_id, row.account_id.startsWith('dev:')),
personal: row.visibility === 'private' ? personalOwner(row.user_id, row.account_id.startsWith('dev:')) : null,
repo: repo ? repoOwner(row.account_id, repo) : null
}
}
@@ -352,16 +358,22 @@ export type ResolvedSecrets = { env: Record<string, string>; scopes: Record<stri
export async function resolveSandboxSecrets(
db: D1Database,
keyring: Keyring,
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
): Promise<ResolvedSecrets> {
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}`)
@@ -29,7 +29,7 @@ export type ProviderEnv = { env: Record<string, string>; scopes: Record<string,
export async function resolvedProviderEnv(
db: D1Database,
keyring: Keyring,
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
): Promise<ProviderEnv> {
const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row)
// The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time
@@ -45,7 +45,8 @@ const PROVIDER_ENV_CACHE_MS = 60_000
const providerEnvCache = new Map<string, { at: number; value: Promise<ProviderEnv> }>()
export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise<ProviderEnv> {
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
@@ -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<ReturnType<typeof createHarness>>
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<string, string> {
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<string, string> {
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<string, string> = {}, deps: Parameters<typeof createHarness>[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<string, string>) {
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)
})
})
@@ -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)
@@ -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()
})
@@ -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<GitHubRepoIds | nu
}
return entry
}
// Rights can change (an org adds an owner), so hits expire like the repo-access answer.
const RIGHTS_CACHE_MS = 5 * 60_000
const rightsCache = new Map<string, { at: number; value: Promise<GitHubInstallRights | null> }>()
/** `.permissions.admin, .owner.type` from `gh api repos/<slug>`; 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<GitHubInstallRights | null> {
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<null>((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
}
@@ -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 })
})
+22 -2
View File
@@ -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<GitHubRepoIds | null> } = {}
options: {
repoIds?: (repoUrl: string) => Promise<GitHubRepoIds | null>
installRights?: (repoUrl: string) => Promise<GitHubInstallRights | null>
} = {}
): 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<HostedSandboxConfigResponse | null> } | 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
@@ -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()
})
})
@@ -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<HostedSandboxControlPlaneClient['create']>[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<HostedSandboxControlPlaneClient, 'create' | 'destroy'>,
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<boolean> {
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
}
@@ -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
}
@@ -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) => {
@@ -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({
</DropdownMenuContent>
</DropdownMenu>
) : null}
{model.settingsLink && !stacked ? (
<CloudStripSettingsLink label={model.settingsLink} onOpen={actions.openSettings} />
) : 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}
</StripPrimaryButton>
) : null}
{model.settingsLink ? (
<CloudStripSettingsLink
ownLine
label={model.settingsLink}
onOpen={actions.openSettings}
/>
) : null}
</div>
) : null}
<span role="status" className="sr-only">
@@ -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<Exclude<Step, 'renewed'>, 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 (
<div className="bg-background p-4">
<div
className="relative h-[220px] overflow-hidden rounded-md border border-border bg-background"
style={{ width }}
>
<CloudStripLayer>
<CloudAgentSignInStrip
model={stepModel(surface, step)}
prompt={surface === 'held' ? 'Fix the flaky auth test and open a PR' : null}
width={cloudStripWidth(width)}
actions={ACTIONS}
/>
</CloudStripLayer>
</div>
</div>
)
}
const fixture = (surface: Surface, step: Step, width = 900) =>
defineFixture({
width: width + 40,
render: () => <Frame surface={surface} step={step} width={width} />
})
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)
@@ -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,
@@ -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 (
<CloudPaneBannerPortal
pane={pane}
@@ -212,10 +220,10 @@ export function CloudRunningStripPortal({
stripLayer
>
<CloudAgentSignInStrip
model={cloudRunningStripModel(strip, t)}
model={shown.model}
prompt={null}
width={cloudStripWidth(pane.container.clientWidth)}
actions={actions}
actions={shown.actions}
/>
</CloudPaneBannerPortal>
)
@@ -0,0 +1,35 @@
import React from 'react'
import { cn } from '@/lib/class-names'
/**
* CHAT-RENEW-INPLACE: the strip's quiet "Renew in Settings" link, at the end of the actions row, or on
* its own line under the buttons in the narrow stack (design-squad).
*/
export function CloudStripSettingsLink({
label,
onOpen,
ownLine = false
}: {
label: string
onOpen?: () => void
ownLine?: boolean
}): React.JSX.Element | null {
if (!onOpen) {
return null
}
return (
<button
type="button"
data-strip-settings-link
onClick={onOpen}
className={cn(
// 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'
)}
>
{label}
</button>
)
}
@@ -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) {
@@ -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' } }
})
})
@@ -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 }
)
}
@@ -2,7 +2,7 @@
// CHAT-BANNERS B2: RA-11's running strip on a structured chat. Expired shows Renew; renewed confirms
// with no Restart (the chat's next message uses the renewed sign-in) and its × dismisses; a held
// launch's strip wins over it.
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -21,7 +21,8 @@ const stub = vi.hoisted(() => {
cleared: string[]
calls: { paneKey: string; turnEndedFallback?: boolean }[]
openedSettings: number
} = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0 }
renewsRun: string[][]
} = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0, renewsRun: [] }
return state
})
@@ -47,6 +48,9 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({
}))
vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
orcaCloudAgentAccountsLoadedAt: () => 1,
runOrcaCloudAgentSetups: (agents: string[]) => stub.renewsRun.push(agents),
stopOrcaCloudAgentSetups: () => undefined,
reloadOrcaCloudAgentAccounts: async () => undefined,
useOrcaCloudAgentAccounts: () => ({
load: { kind: 'loading' },
keyKinds: {},
@@ -94,6 +98,7 @@ vi.stubGlobal(
)
const { CloudStructuredChatBanners } = await import('./CloudStructuredChatBanners')
const { useCloudChatSendBlock } = await import('./use-cloud-chat-running-strip')
const mount = () =>
render(
@@ -117,7 +122,9 @@ describe('the running strip on a structured chat (B2)', () => {
mount()
expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: /Renew/ }))
expect(stub.openedSettings).toBe(1)
// CHAT-RENEW-INPLACE: Renew runs the renewal right here, never Settings.
expect(stub.renewsRun).toEqual([['claude']])
expect(stub.openedSettings).toBe(0)
expect(stub.calls.at(-1)).toEqual({ paneKey: STATUS_KEY, turnEndedFallback: true })
})
@@ -139,3 +146,54 @@ describe('the running strip on a structured chat (B2)', () => {
expect(screen.queryByRole('button', { name: /Renew/ })).toBeNull()
})
})
describe('an idle chat (no message yet, no status entry)', () => {
const IDLE_TAB = 'structured-agent-session-claude_idle'
const idle = () =>
render(
<TooltipProvider>
<CloudStructuredChatBanners
tabId={TAB_ID}
paneKey={HOLD_KEY}
statusKey={`${IDLE_TAB}:no-status`}
agent="claude"
/>
</TooltipProvider>
)
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()
})
})
@@ -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' })
})
})
@@ -0,0 +1,161 @@
// CHAT-RENEW-INPLACE (Option A, design-squad per-step copy): every "sign-in expired" strip — a held
// launch, a running terminal, a structured chat — renews in place. Renew runs the same Orca Cloud
// setup as "Set up once" right in the strip (re-running it replaces the account's expired sign-in),
// instead of sending the person to Settings. One model and one set of actions for every surface, so
// the terminal and the chat can't drift apart.
// 1 expired: [Renew] 2 browser: paste code, [Finish renewing] [Open again] ×
// 3 checking: field and buttons disabled 4 rejected: "Code didn't work", [Open again]
// 5 renewed: the surface's own renewed state (held: Start; terminal: Restart; chat: next message)
// × mid-flow stops the setup and goes back to step 1; it never dismisses the strip.
import type { Translate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import {
runOrcaCloudAgentSetups,
stopOrcaCloudAgentSetups
} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
import type { CloudStripActions } from './CloudAgentSignInStrip'
import type { CloudStripModel } from './cloud-agent-strip-model'
import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model'
import { cloudSetupStripActions } from './cloud-setup-strip-actions'
import { agentSignInCardActions } from '../agent-sign-in/AgentSignInPanePortals'
/** The renewal's own setup is under way (steps 2-4): the strip shows it instead of step 1. */
export function renewInPlaceRunning(setup: OrcaCloudAgentSetup): boolean {
return setup.phase !== 'idle' && setup.phase !== 'saved'
}
/**
* Steps 1-4 over a surface's "expired" model (step 1 keeps the surface's own detail: running "Renew
* to keep working…", held "Renew to start {{agent}}."). Step 5 is each surface's renewed state.
*/
export function cloudRenewInPlaceModel(args: {
agent: HostAgentSignInAgent
expired: CloudStripModel
setup: OrcaCloudAgentSetup
t: Translate
}): CloudStripModel {
const { expired, setup, t } = args
if (!renewInPlaceRunning(setup)) {
return expired
}
const flow = cloudAgentSetupStripModel({ agent: args.agent, setup, ready: false, t })
const settingsLink = t('agentSignInStrip.renewInSettings', 'Renew in Settings')
// A failed setup keeps its own words; a refused code says "Code didn't work" (as #484).
const keepsTitle =
setup.phase === 'failed' || (setup.phase === 'browser' && setup.code === 'rejected')
const finishRenewing = t('agentSignInStrip.finishRenewing', 'Finish renewing')
const paste = flow.paste
? {
...flow.paste,
// Never the setup's "Finish setup" (design-squad).
...(flow.paste.setupCode
? {
setupCode: {
...flow.paste.setupCode,
submitLabel:
setup.phase === 'browser' && setup.code === 'checking'
? flow.paste.setupCode.submitLabel
: finishRenewing
}
}
: {}),
...(flow.paste.submitLabel ? { submitLabel: finishRenewing } : {})
}
: null
return {
...flow,
title: keepsTitle ? flow.title : expired.title,
paste,
// A setup that failed outright starts again from Renew.
primary:
setup.phase === 'failed' && flow.primary?.action.kind === 'setUpOnce'
? { ...flow.primary, action: { kind: 'renew' } }
: flow.primary,
...(setup.phase === 'browser' || setup.phase === 'paste' ? { settingsLink } : {}),
announcement: keepsTitle ? flow.announcement : `${expired.title}. ${flow.announcement}`
}
}
/**
* Renew starts the setup here; Open again, the code and the paste go to it; × stops it (back to
* step 1). With no renewal running, everything else is the surface's own (its × or Restart).
*/
export function cloudRenewInPlaceActions(args: {
agent: HostAgentSignInAgent
setup: OrcaCloudAgentSetup
/** The surface's own actions, used outside the renewal (Restart, a renewed strip's ×). */
surface: CloudStripActions
/** "Renew in Settings": the same renewal in Settings › Accounts › Orca Cloud. */
openSettings: () => void
copyText: (text: string) => void
openLink: (url: string) => void
}): CloudStripActions {
const running = renewInPlaceRunning(args.setup)
const flow = cloudSetupStripActions({
agent: args.agent,
// Renewing holds nothing itself: Start (step 5) belongs to the surface.
holdKey: '',
setupRunning: running,
copyText: args.copyText,
openLink: args.openLink,
useApiKey: args.surface.useApiKey,
keepHoldOnCancel: true
})
return {
...args.surface,
primary: (action) => {
if (action.kind === 'renew') {
runOrcaCloudAgentSetups([args.agent])
} else if (action.kind === 'reopenSetup' || action.kind === 'copyAndOpen') {
flow.primary(action)
} else {
args.surface.primary(action)
}
},
submitCode: running ? flow.submitCode : args.surface.submitCode,
submitSetupCode: flow.submitSetupCode,
// "Copy link" beside Open again: the renewal's sign-in page, as for "Set up once".
linkFor: running ? flow.linkFor : args.surface.linkFor,
pasteCode: running ? flow.pasteCode : args.surface.pasteCode,
openSettings: args.openSettings,
cancel: () => (running ? stopOrcaCloudAgentSetups(args.agent) : args.surface.cancel())
}
}
/** "Renew in Settings": Settings › Accounts › Orca Cloud, where the same renewal runs. */
export function openOrcaCloudAccountsSettings(): void {
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
store.openSettingsPage()
}
/** A surface's expired strip, renewing in place: its model (steps 1-4) and actions together. */
export function withRenewInPlace(args: {
agent: HostAgentSignInAgent
setup: OrcaCloudAgentSetup
model: CloudStripModel
actions: CloudStripActions
/** The pane or chat key, for the sign-in page's copy and open. */
key: string
t: Translate
}): { model: CloudStripModel; actions: CloudStripActions } {
const page = agentSignInCardActions(args.key)
return {
model: cloudRenewInPlaceModel({
agent: args.agent,
expired: args.model,
setup: args.setup,
t: args.t
}),
actions: cloudRenewInPlaceActions({
agent: args.agent,
setup: args.setup,
surface: args.actions,
openSettings: openOrcaCloudAccountsSettings,
copyText: page.copyText,
openLink: page.openLink
})
}
}
@@ -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') {
@@ -4,7 +4,10 @@
// with the renewed sign-in.
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
import {
HOST_AGENT_SIGN_IN_AGENT_NAME,
type HostAgentSignInAgent
} from '../../../../shared/host-agent-sign-in-status'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { useCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold'
import {
@@ -15,6 +18,7 @@ import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environmen
import {
clearCloudRunningSignout,
cloudRunningStrip,
type CloudRunningStrip,
useCloudRunningAccountReads,
useCloudRunningSignoutVersion,
useWorkspaceUsesAccount
@@ -22,12 +26,7 @@ import {
import type { CloudStripActions } from './CloudAgentSignInStrip'
import type { CloudHeldStrip } from './use-cloud-held-strip'
import { cloudChatRunningStripModel } from './cloud-running-strip-model'
function openAccounts(): void {
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
store.openSettingsPage()
}
import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place'
/** The Orca Cloud environment a structured chat's tab runs on, or null. */
function useChatEnvironmentId(tabId: string): string | null {
@@ -43,27 +42,33 @@ function useChatEnvironmentId(tabId: string): string | null {
})
}
/**
* statusKey: the chat's agent-status key (its tab and session); holdKey: its launch-hold key. A held
* launch shows the held strip instead.
*/
export function useCloudChatRunningStrip(args: {
type ChatAccountArgs = {
tabId: string
statusKey: string
holdKey: string
}): CloudHeldStrip | null {
const t = useTranslate()
/** The chat's own agent: an idle chat (no message yet) has no status entry to name it. */
agent?: string
}
/**
* The RA-11 decision for a structured chat: expired / renewed / none. An idle chat with no status
* entry is decided from its own agent, so the strip shows as soon as the account expires, before a
* first message could fail. A held launch wins (null here).
*/
export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStrip | null {
const revision = useCloudRunningSignoutVersion()
const environmentId = useChatEnvironmentId(args.tabId)
const cloud = environmentId !== null && isOrcaCloudEnvironmentId(environmentId)
const entry = useAppStore((state) =>
const status = useAppStore((state) =>
cloud ? (state.agentStatusByPaneKey[args.statusKey] ?? null) : null
)
const agentType = status?.agentType ?? args.agent
const agent: HostAgentSignInAgent | null =
entry?.agentType === 'claude' ? 'claude' : entry?.agentType === 'codex' ? 'codex' : null
const running = cloud && agent !== null
useCloudRunningAccountReads(running)
const accounts = useOrcaCloudAgentAccounts(running)
agentType === 'claude' ? 'claude' : agentType === 'codex' ? 'codex' : null
const watched = cloud && agent !== null
// Focus and the 30 s poll keep the account read fresh for any open cloud chat of an account agent.
useCloudRunningAccountReads(watched)
const accounts = useOrcaCloudAgentAccounts(watched)
// D13: only a workspace that runs the agent on the person's own account (never shared or keyed).
const credential =
agent && accounts.load.kind === 'ready' ? accounts.load.status.credentials[agent] : null
@@ -73,18 +78,19 @@ export function useCloudChatRunningStrip(args: {
credential.state !== 'expiring' &&
credential.state !== 'none'
const usesAccount = useWorkspaceUsesAccount(
running ? environmentId : null,
watched ? environmentId : null,
agent,
expired,
orcaCloudAgentAccountsLoadedAt()
)
const held = useCloudAgentHold(args.holdKey) !== null
if (!running) {
if (!watched || !agent) {
return null
}
const strip = cloudRunningStrip({
return cloudRunningStrip({
paneKey: args.statusKey,
entry,
// An idle chat (no message yet) has no status entry: decided from its own agent.
entry: status ?? { agentType: agent, state: 'waiting' },
accounts,
held,
now: orcaCloudAgentAccountsLoadedAt(),
@@ -92,11 +98,18 @@ export function useCloudChatRunningStrip(args: {
revision,
turnEndedFallback: true
})
}
/** The strip a structured chat shows for its account while its agent runs or waits idle. */
export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip | null {
const t = useTranslate()
const strip = useCloudChatAccountStrip(args)
const accounts = useOrcaCloudAgentAccounts(strip !== null)
if (!strip) {
return null
}
const actions: CloudStripActions = {
primary: (action) => (action.kind === 'renew' ? openAccounts() : undefined),
primary: (action) => (action.kind === 'renew' ? openOrcaCloudAccountsSettings() : undefined),
submitCode: () => undefined,
pasteCode: () => undefined,
useCode: () => undefined,
@@ -104,5 +117,32 @@ export function useCloudChatRunningStrip(args: {
// Expired has no ×; renewed's × dismisses.
cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(args.statusKey) : undefined)
}
return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions }
const model = cloudChatRunningStripModel(strip, t)
// CHAT-RENEW-INPLACE: the chat's "sign-in expired · Renew" renews right here.
const shown =
strip.phase === 'expired'
? withRenewInPlace({
agent: strip.agent,
setup: accounts.setups[strip.agent],
model,
actions,
key: args.statusKey,
t
})
: { model, actions }
return { ...shown, prompt: null }
}
/**
* While the chat's account is expired, typing stays open and only Send is held, with this hint
* (the person's draft is never lost); Renew lifts it at once.
*/
export function useCloudChatSendBlock(args: ChatAccountArgs): string | undefined {
const t = useTranslate()
const strip = useCloudChatAccountStrip(args)
return strip?.phase === 'expired'
? t('orcaCloud.chatSend.renewAbove', 'Renew {{agent}} sign-in above to send', {
agent: HOST_AGENT_SIGN_IN_AGENT_NAME[strip.agent]
})
: undefined
}
@@ -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,
@@ -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(
<TooltipProvider>
<Dialog open>
<TeamDialogContent>
<p>Team</p>
</TeamDialogContent>
</Dialog>
</TooltipProvider>
)
const dialog = document.querySelector<HTMLElement>('[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')
})
@@ -234,6 +234,13 @@ export function TeamPageBody({
openTeammateProfile(userId)
}}
onDismissArrival={clearTeamPageArrival}
onOpenEnterprise={() => {
// SSO, SCIM and the audit log live in Settings › Enterprise (#614); the dialog points there.
closeTeamPage()
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'cloud-enterprise', repoId: null })
store.openSettingsPage()
}}
inline={inline}
dangerZone={
org && teamDelete.check ? (
@@ -251,13 +258,18 @@ export function TeamPageBody({
)
}
/** The Team page's dialog surface: top-anchored so loading and checkout arrivals never jump. */
/**
* The Team page's dialog surface, centred in the window like every other dialog, whichever entry
* point opened it (account menu, Create a team, the post-checkout hand-off). It used to be pinned
* 6vh from the top (`top-[6vh] translate-y-0`), so it sat high on screen (TEAM-MODAL-CENTER).
*/
export function TeamDialogContent({ children }: { children: React.ReactNode }): React.JSX.Element {
const t = useTranslate()
return (
<DialogContent
scrollable
className="top-[6vh] max-h-[88vh] translate-y-0 sm:max-w-2xl"
data-team-dialog=""
className="max-h-[88vh] sm:max-w-2xl"
onOpenAutoFocus={(event) => {
event.preventDefault()
if (event.currentTarget instanceof HTMLElement) {
@@ -89,7 +89,8 @@ const base: TeamPageViewProps = {
onSeePlans: noop,
onManageBilling: noop,
onCreateWorkspace: noop,
onDismissArrival: noop
onDismissArrival: noop,
onOpenEnterprise: noop
}
function Page(props: Partial<TeamPageViewProps>): React.JSX.Element {
@@ -104,6 +105,21 @@ function Page(props: Partial<TeamPageViewProps>): React.JSX.Element {
export const ownerOnTeam = defineFixture({ width, render: () => <Page /> })
/** TEAM-MODAL-CENTER: in a tall window the dialog sits in the middle, not pinned near the top. */
export const centeredInWindow = defineFixture({
width,
render: () => (
<div className="h-[1000px]">
<Page
roster={{
status: 'ok',
roster: { ...roster, members: roster.members.slice(0, 2), pendingInvites: [] }
}}
/>
</div>
)
})
export const justUpgraded = defineFixture({
width,
render: () => <Page arrival={{ kind: 'checkout-success', seats: 4 }} />
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
import { sampleTeam } from '../plan-billing-fixtures'
@@ -61,3 +61,40 @@ it('keeps the header to the name and counts: no role sentence, no Plan & billing
// Seat counts and prices live only in Plan & billing.
expect(screen.queryByText(/open seat/)).toBeNull()
})
it('points owners and admins to Settings › Enterprise with one plain button', () => {
const onOpenEnterprise = vi.fn()
for (const viewerRole of ['owner', 'admin'] as const) {
render(
<TeamPageView
{...props}
roster={{ status: 'ok', roster: { ...roster, viewerRole } }}
onOpenEnterprise={onOpenEnterprise}
/>,
{ wrapper: TooltipProvider }
)
const row = screen.getByRole('region', {
name: 'Single sign-on, directory sync and the audit log'
})
// No Copy link split: the row has exactly one control.
expect(row.querySelectorAll('button')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Open Enterprise settings' }))
cleanup()
}
expect(onOpenEnterprise).toHaveBeenCalledTimes(2)
})
it('hides the Enterprise row from plain members', () => {
render(
<TeamPageView
{...props}
roster={{
status: 'ok',
roster: { ...roster, viewerRole: 'member', canManageMembers: false }
}}
onOpenEnterprise={() => {}}
/>,
{ wrapper: TooltipProvider }
)
expect(screen.queryByText('Single sign-on, directory sync and the audit log')).toBeNull()
})
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { RefreshCw } from 'lucide-react'
import { KeyRound, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
@@ -57,6 +57,8 @@ export type TeamPageViewProps = {
inline?: { onOpenPlanBilling: () => void }
/** Owners in Settings › Team: "Delete <team>", after "Leave <team>" (TEAM-DELETE). */
dangerZone?: React.ReactNode
/** Opens Settings › Enterprise (SSO, SCIM, audit log). Shown to owners and admins only. */
onOpenEnterprise?: () => void
}
function SkeletonRows(): React.JSX.Element {
@@ -110,6 +112,10 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
const rows = roster ? teamRows(roster, props.viewerUserId) : []
const selfRow = rows.find((row) => row.self)
const viewerRole = roster?.viewerRole ?? 'member'
// Matches who sees Settings › Enterprise: team owners and admins (Enterprise or not).
const showEnterprise =
Boolean(props.onOpenEnterprise) &&
(roster?.viewerRole === 'owner' || roster?.viewerRole === 'admin')
const canLeave =
Boolean(selfRow) &&
(viewerRole !== 'owner' || (roster ? otherOwnerCount(roster, props.viewerUserId) > 0 : false))
@@ -278,8 +284,33 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
) : null}
</section>
{props.dangerZone ? (
{showEnterprise || props.dangerZone ? (
<div className="divide-y divide-border/60 rounded-xl border border-border">
{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.
<section
aria-labelledby="team-enterprise-title"
className="flex flex-wrap items-center gap-3 p-4"
>
<h3
id="team-enterprise-title"
className="flex min-w-[14rem] flex-1 items-center gap-2 text-sm font-medium"
>
<KeyRound aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{t(
'auto.components.billing.team.enterpriseRowTitle',
'Single sign-on, directory sync and the audit log'
)}
</h3>
<Button type="button" variant="outline" size="sm" onClick={props.onOpenEnterprise}>
{t(
'auto.components.billing.team.openEnterpriseSettings',
'Open Enterprise settings'
)}
</Button>
</section>
) : null}
{props.dangerZone}
</div>
) : null}
@@ -124,12 +124,11 @@ describe('Open cloud workspace…', () => {
expect(getCloudWorkspacePickerRequest()).not.toBeNull()
})
it('with everything in the sidebar, says so instead of inviting a first workspace', async () => {
it('with everything in the sidebar, says there is nothing else to open', async () => {
listResult = { ok: true, value: [MINE] }
renderPicker()
act(() => openCloudWorkspacePicker())
expect(await screen.findByText('Your cloud workspaces are already in the sidebar')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'New cloud workspace' })).toBeNull()
expect(await screen.findByText('Nothing else to open')).toBeTruthy()
})
it('a list that could not be read offers Try again', async () => {
@@ -58,19 +58,27 @@ it('at the running limit, Show cloud workspaces opens the picker on your running
closeCloudWorkspacePicker()
})
it('with none at all, invites the first cloud workspace with one button (JA-30)', () => {
it('with none at all, says so and offers New cloud workspace as the primary action', () => {
const onCreate = vi.fn()
render(<PickerEmptyState reason={{ kind: 'none' }} onCreate={onCreate} />)
expect(screen.getByText('Start your first cloud workspace')).toBeTruthy()
expect(screen.getByText('No cloud workspaces yet')).toBeTruthy()
expect(screen.getByText('Start one and it shows up in your sidebar.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New cloud workspace' }))
expect(onCreate).toHaveBeenCalledTimes(1)
})
it('every other empty list keeps its own copy and offers no create button', () => {
it('with everything in the sidebar, says there is nothing else and keeps a secondary New', () => {
render(<PickerEmptyState reason={{ kind: 'all-in-sidebar' }} onCreate={vi.fn()} />)
expect(screen.getByText('Your cloud workspaces are already in the sidebar')).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
cleanup()
expect(screen.getByText('Nothing else to open')).toBeTruthy()
expect(
screen.getByText('Every cloud workspace you can open is already in your sidebar.')
).toBeTruthy()
expect(
screen.getByRole('button', { name: 'New cloud workspace' }).getAttribute('data-variant')
).toBe('outline')
})
it('a search with no match keeps its own copy and offers no create button', () => {
render(<PickerEmptyState reason={{ kind: 'search' }} onCreate={vi.fn()} />)
expect(screen.getByText('Nothing matches your search')).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
@@ -104,7 +104,7 @@ export function PickerEmptyState({
onCreate
}: {
reason: PickerEmptyReason
/** Offered only when there are none at all: start one. */
/** Start one: primary with none at all, secondary when all are in the sidebar. */
onCreate?: () => void
}): React.JSX.Element {
const t = useTranslate()
@@ -128,13 +128,10 @@ export function PickerEmptyState({
]
case 'all-in-sidebar':
return [
t('components.cloudWorkspaces.picker.emptyNothingElseTitle', 'Nothing else to open'),
t(
'components.cloudWorkspaces.picker.emptyAllInSidebarTitle',
'Your cloud workspaces are already in the sidebar'
),
t(
'components.cloudWorkspaces.picker.emptyAllInSidebarBody',
'Workspaces teammates share, and ones you start on another computer, show up here.'
'components.cloudWorkspaces.picker.emptyNothingElseBody',
'Every cloud workspace you can open is already in your sidebar.'
)
]
case 'none-running':
@@ -159,10 +156,10 @@ export function PickerEmptyState({
]
case 'none':
return [
t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'),
t('components.cloudWorkspaces.picker.emptyNoneTitle', 'No cloud workspaces yet'),
t(
'components.cloudWorkspaces.emptyStartBody',
'It runs in a sandbox, so your agents keep working even when this computer is off.'
'components.cloudWorkspaces.picker.emptyNoneBody',
'Start one and it shows up in your sidebar.'
)
]
}
@@ -171,8 +168,15 @@ export function PickerEmptyState({
<div className="flex flex-col items-center gap-1 px-6 py-8 text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="max-w-sm text-xs text-muted-foreground">{body}</p>
{reason.kind === 'none' && onCreate ? (
<Button type="button" size="sm" className="mt-3" onClick={onCreate}>
{(reason.kind === 'none' || reason.kind === 'all-in-sidebar') && onCreate ? (
// Primary when there are none at all; secondary when everything is already in the sidebar.
<Button
type="button"
size="sm"
variant={reason.kind === 'none' ? 'default' : 'outline'}
className="mt-3"
onClick={onCreate}
>
<Cloud className="size-3.5" />
{t('components.cloudWorkspaces.newCloudWorkspace', 'New cloud workspace')}
</Button>
@@ -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<string | null> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const fetched = await Promise.race([
window.api.cloudWorkspaces.get(id),
new Promise<null>((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<string> {
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<boolean> {
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
}
@@ -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<OpenOutcome> {
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<OpenOutcome> {
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<OpenOutcome | nu
}
}
async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise<OpenOutcome> {
async function pairAndReveal(
workspace: CloudWorkspace,
name: string,
options: OpenCloudWorkspaceOptions
): Promise<OpenOutcome> {
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<O
return refused
}
try {
return await connectAndReveal(binding.environmentId, name)
return await connectAndReveal(binding.environmentId, name, options)
} catch (error) {
// Only a rejected pairing is fixed by pairing again; anything else is surfaced as is.
if (!isPairingRejected(error)) {
@@ -213,10 +228,13 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise<O
}
// Messages to this workspace now carry the sender line if it is shared with the team.
void refreshCloudMessageSenders().catch(() => undefined)
return await connectAndReveal(added.environment.id, name)
return await connectAndReveal(added.environment.id, name, options)
}
async function openOnce(workspace: CloudWorkspace): Promise<OpenOutcome> {
async function openOnce(
workspace: CloudWorkspace,
options: OpenCloudWorkspaceOptions
): Promise<OpenOutcome> {
const name = cloudWorkspaceTitle(workspace)
try {
if (workspace.access === 'owner') {
@@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise<OpenOutcome> {
)
}
}
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<OpenOutcome> {
// instead of minting parallel grants.
const opening = new Map<string, Promise<OpenOutcome>>()
export function openCloudWorkspace(workspace: CloudWorkspace): Promise<OpenOutcome> {
export function openCloudWorkspace(
workspace: CloudWorkspace,
options: OpenCloudWorkspaceOptions = {}
): Promise<OpenOutcome> {
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<string | null> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const fetched = await Promise.race([
window.api.cloudWorkspaces.get(id),
new Promise<null>((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<string> {
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<boolean> {
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'
@@ -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.'
)
})
}
@@ -21,6 +21,7 @@ import type { RowActions } from './CloudWorkspaceRowMenu'
import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog'
import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet'
import { useCloudReadPanel } from './use-cloud-read-panel'
import { toastSharedWithTeam } from './shared-with-team-toast'
/** Names for every member id the rows show, and whether you are a team owner or admin. */
export function useMemberDirectory(
@@ -130,9 +131,7 @@ export function useCloudWorkspaceRowActions({
void refreshCloudMessageSenders().catch(() => undefined)
}
if (change.visibility === 'team') {
toast.success(
t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team')
)
toastSharedWithTeam(t)
} else if (change.visibility === 'private') {
toast.success(
t(
@@ -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<NativeChatStructuredViewProps, 'target' | 'agent' | 'isVisible' | 'sessionId'>,
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
}
}
@@ -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 =
@@ -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 })])
@@ -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<string> => '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(<CloudSetupNoticesDock workspaceId="w1" />)
// 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(<CloudSetupNoticesDock workspaceId="w1" />)
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()
}
})
})
@@ -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<CloudSetupNoticesRecord, 'repoUrl'>,
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 ? (
<Loader2 className="animate-spin" />
) : (
<GitPullRequestArrow />
),
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<number | null>(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,
@@ -12,6 +12,7 @@ import {
} from 'lucide-react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { useTranslate } from '@/i18n/use-translate'
import { toastSharedWithTeam } from '@/components/cloud-workspaces/shared-with-team-toast'
import { isCloudSyncAvailable, requestCloudSyncFocus } from '@/lib/orca-cloud/cloud-sync-store'
import {
ConfirmationDialogContext,
@@ -178,14 +179,16 @@ async function setVisibility(
)
return
}
toast.success(
visibility === 'team'
? t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team')
: t(
'auto.components.cloudWorkspaces.nowPrivate',
'Now private. Teammates’ devices were disconnected.'
)
)
if (visibility === 'team') {
toastSharedWithTeam(t)
} else {
toast.success(
t(
'auto.components.cloudWorkspaces.nowPrivate',
'Now private. Teammates’ devices were disconnected.'
)
)
}
reload()
}
@@ -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<CloudSetupNoticeLine['action']> {
return {
label: h.copied
? t('orcaCloud.setupNotices.copied', 'Copied')
: t('orcaCloud.setupNotices.copyRequest', 'Copy request for an owner'),
icon: h.copied ? <Check /> : <Copy />,
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 ? <Loader2 className="animate-spin" /> : <GitPullRequestArrow />,
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 ? <Loader2 className="animate-spin" /> : <RefreshCw />,
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
}
}
}
@@ -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<number | null>(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<ReturnType<typeof setTimeout> | 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
}
}
@@ -8,8 +8,7 @@ export const ACCOUNT_MFA_WATCH_MAX_MS = 5 * 60_000
export type AccountMfaState =
/** Not read yet, every read failed, or this deployment has no MFA status: the row is hidden. */
| { kind: 'unknown' }
| ({ kind: 'known' } & OrcaAccountMfa)
{ kind: 'unknown' } | ({ kind: 'known' } & OrcaAccountMfa)
export type AccountMfa = {
state: AccountMfaState
@@ -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."
/>
</Frame>
)
@@ -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(
@@ -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' })
})
})
@@ -0,0 +1,64 @@
// @vitest-environment happy-dom
// A cloud create's billing refusal is a precondition, not an error: its sidebar status reads in
// muted text (the glyph carries the state); a real failure stays red.
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
import { PendingWorktreeRow } from './PendingWorktreeRow'
afterEach(cleanup)
function failed(
errorCode: string | undefined,
log: string,
error: string
): PendingWorktreeCreation {
return {
creationId: 'c',
phase: 'provisioning-vm',
status: 'error',
startedAt: 1,
indeterminate: false,
loaderVisible: true,
error,
...(errorCode ? { errorCode } : {}),
provisioningLog: log,
request: githubCreateRequest({
repoUrl: 'https://github.com/acme/web',
fullName: 'acme/web',
prompt: 'Fix login',
agent: null
})
}
}
function statusLine(entry: PendingWorktreeCreation): HTMLElement {
useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] })
render(<PendingWorktreeRow creationId="c" repoName={null} />)
const line = screen.getByText('Fix login').nextElementSibling
if (!(line instanceof HTMLElement)) {
throw new Error('no status line')
}
return line
}
describe('pending row status colour', () => {
it('billing refusal: muted', () => {
const line = statusLine(
failed(
'cloudPlanRequired',
'[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted',
'Orca Cloud workspaces need a plan with cloud hours.'
)
)
expect(line.className).toContain('text-muted-foreground')
expect(line.className).not.toContain('text-destructive')
})
it('a real failure: red', () => {
const line = statusLine(failed(undefined, '[orca-cloud] provisioning', 'fetch failed'))
expect(line.className).toContain('text-destructive')
})
})
@@ -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 (
<div
className={cn(
@@ -104,7 +113,7 @@ export function PendingWorktreeRow({
<span
className={cn(
'block truncate text-[11px] tabular-nums',
isError ? 'text-destructive/90' : 'text-muted-foreground'
isError && !refused ? 'text-destructive/90' : 'text-muted-foreground'
)}
>
{statusLabel(entry, repos, t)}
@@ -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> = {}
): 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: () => (
<div className="flex flex-col gap-1.5 bg-worktree-sidebar p-2 text-worktree-sidebar-foreground">
{Object.keys(rows).map((creationId) => (
<PendingWorktreeRow key={creationId} creationId={creationId} repoName={null} />
))}
</div>
)
})
+33 -11
View File
@@ -21370,6 +21370,8 @@
"perSeatMonthValue": "{{value0}}/mo",
"seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.",
"inviteSeatRule": "Each invite uses a seat once accepted.",
"enterpriseRowTitle": "Single sign-on, directory sync and the audit log",
"openEnterpriseSettings": "Open Enterprise settings",
"mfaSso": "2FA via SSO"
},
"manageBilling": "Manage billing",
@@ -21713,7 +21715,8 @@
"errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.",
"errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.",
"errorTeamSandboxLimitNoSeat_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.",
"errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again."
"errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.",
"sharedPersonalSecretsNote": "Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are."
},
"orcaCloud": {
"QueuedPrompt": {
@@ -24499,8 +24502,6 @@
},
"cloudWorkspaces": {
"newCloudWorkspace": "New cloud workspace",
"emptyStartBody": "It runs in a sandbox, so your agents keep working even when this computer is off.",
"emptyFirstTitle": "Start your first cloud workspace",
"picker": {
"title": "Open cloud workspace",
"description": "Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.",
@@ -24516,8 +24517,6 @@
"inSidebarNote_other": "{{count}} more are already in your sidebar.",
"emptySearchTitle": "Nothing matches your search",
"emptySearchBody": "Try a title, repository, branch, owner or W-number.",
"emptyAllInSidebarTitle": "Your cloud workspaces are already in the sidebar",
"emptyAllInSidebarBody": "Workspaces teammates share, and ones you start on another computer, show up here.",
"emptyRunningTitle": "None of yours are running",
"emptyRunningBody": "Teammates’ running workspaces can count toward the limit too. Try again in a moment.",
"emptyTeammateTitle": "{{name}} has no cloud workspaces you can see",
@@ -24529,7 +24528,11 @@
"sharedWorkspace": "shared workspace",
"teammate": "teammate",
"orcaCloud": "orca cloud"
}
},
"emptyNothingElseTitle": "Nothing else to open",
"emptyNothingElseBody": "Every cloud workspace you can open is already in your sidebar.",
"emptyNoneTitle": "No cloud workspaces yet",
"emptyNoneBody": "Start one and it shows up in your sidebar."
}
},
"worktreeCreation": {
@@ -28226,9 +28229,9 @@
},
"setUpAgent": "Set up an agent",
"personal": {
"description": "Only you get these, in every Orca Cloud workspace you start.",
"emptyTitle": "No personal secrets yet",
"emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables."
"emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.",
"descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them."
},
"org": {
"description": "Everyone on your team gets these in their Orca Cloud workspaces.",
@@ -28462,6 +28465,9 @@
"setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.",
"notStartedRow": "{{agent}} hasn’t started"
},
"chatSend": {
"renewAbove": "Renew {{agent}} sign-in above to send"
},
"chatSignIn": {
"renewAbove": "Your {{agent}} sign-in for Orca Cloud didn’t work. Renew it above, then send your message again."
},
@@ -28512,7 +28518,21 @@
"fetchFailedNoRepo": "This workspace can’t reach its Git remote right now.",
"checking": "Checking…",
"checkAgain": "Check again",
"fetchFailedMeaning": "Pulls and pushes won’t work until it can."
"fetchFailedMeaning": "Pulls and pushes won’t work until it can.",
"toolMissing": "{{command}} isn’t installed in this sandbox, but {{reason}} needs it.",
"toolMissingNoReason": "{{command}} isn’t installed in this sandbox.",
"toolMissingMeaning": "Install it from a terminal here, or add it to your project’s setup script so new workspaces have it.",
"diskLow": "Only {{free}} GB of disk space is free in this sandbox.",
"diskVeryLowMeaning": "Installs and builds may fail. Delete caches or build output you don’t need.",
"diskLowMeaning": "Large dependency installs may run out of space.",
"pushRequested": "Orca Cloud can read {{repo}} but can’t push yet. An owner of {{owner}} has been asked to approve the Orca GitHub App.",
"copied": "Copied",
"copyRequest": "Copy request for an owner",
"pushCantInstall": "Orca Cloud can read {{repo}} but can’t push to it.",
"pushCantInstallMeaning": "Pushing needs the Orca GitHub App on {{owner}}. Ask an owner of {{owner}} to install it.",
"pushNotYet": "Orca Cloud can’t push to {{repo}} yet.",
"pushRequestMessage": "Could you give the Orca GitHub App access to {{repo}}? Orca Cloud needs it to push from workspaces on this repository.",
"pushRequestCopyFailed": "Couldn’t copy it."
},
"toast": {
"dismiss": "Dismiss"
@@ -29064,6 +29084,10 @@
"nothingNeedsAttention": "Nothing needs attention."
},
"agentSignInStrip": {
"renewInSettings": "Renew in Settings",
"finishRenewing": "Finish renewing",
"expiredHeldDetail": "Renew to start {{agent}}.",
"expiredHeldAnnounce": "Your {{agent}} sign-in expired. Renew to start {{agent}}.",
"rowNeedsSignIn": "Needs sign-in",
"rowNeedsSignInTooltip": "Set up once for Orca Cloud",
"rowExpired": "Sign-in expired",
@@ -29083,9 +29107,7 @@
"useMySignIn": "Use my {{agent}} sign-in",
"signIn": "Sign in",
"expired": "Your {{agent}} sign-in expired",
"expiredDetail": "Sign in once to fix every workspace.",
"renew": "Renew",
"expiredAnnounce": "Your {{agent}} sign-in expired. Renew it once to fix every workspace.",
"waiting": "Approve on {{site}}",
"openAgain": "Open again",
"waitingAnnounce": "Waiting for you to approve in your browser.",
@@ -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 () => {
@@ -112,11 +112,7 @@ type HoldRequest = Omit<CloudAgentHold, 'reason' | 'awaiting'> & {
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<string, Entry>()
/** 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'
@@ -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))
)
}
@@ -1,128 +1,128 @@
[
{
"path": "src/renderer/src/components/cloud-environment/EnvironmentForm.tsx",
"line": 127,
"term": "container",
"match": "Reads Conductor and Dev Container setup from a local project.",
"reason": "Dev Containers spec name"
},
{
"path": "src/renderer/src/components/settings/virtual-machines/runtimes/CloudVmSetupGuide.tsx",
"line": 20,
"term": "VM",
"match": "Cloud VMs",
"reason": "BYOC recipe VMs (experimental), not Orca Cloud"
},
{
"path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx",
"line": 372,
"term": "VM",
"match": "Delete VM",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx",
"line": 138,
"term": "VM",
"match": "The VM is running until you choose.",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
"line": 38,
"term": "VM",
"match": "Delete this VM?",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
"line": 49,
"term": "VM",
"match": "The VM and anything on it are destroyed, and the workspace isn’t created.",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
"line": 63,
"term": "VM",
"match": "Delete VM",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
"line": 55,
"term": "VM",
"match": "The VM is up, but some environment checks need attention",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
"line": 59,
"term": "VM",
"match": "The VM is up, but {{count}} environment checks failed",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
"line": 62,
"term": "VM",
"match": "The VM is up, but {{count}} environment check failed",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
"line": 15,
"term": "VM",
"match": "The VM recipe failed",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
"line": 19,
"term": "VM",
"match": "Your VM recipe stopped with an error before the VM was ready.",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
"line": 25,
"term": "VM",
"match": "Couldn’t create the workspace on the VM",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
"line": 29,
"term": "VM",
"match": "The VM started, but creating the workspace on it stopped with an error.",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
"line": 10,
"term": "VM",
"match": "Provision VM",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
"line": 69,
"term": "VM",
"match": "Building your VM workspace",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
"line": 74,
"term": "VM",
"match": "Your VM workspace is ready",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
"line": 79,
"term": "VM",
"match": "Your VM workspace needs attention",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
"line": 84,
"term": "VM",
"match": "Couldn’t build your VM workspace",
"reason": "recipe-VM branch; cloud branch says sandbox"
},
{
"path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts",
"line": 13,
"term": "VM",
"match": "This workspace's VM is asleep",
"reason": "match pattern for older builds' saved terminal errors; never rendered"
},
{
"path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts",
"line": 14,
"term": "VM",
"match": "This workspace's VM could not wake up",
"reason": "match pattern for older builds' saved terminal errors; never rendered"
}
]
@@ -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> = {}): 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])
})
})
@@ -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' }))
})
@@ -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')
})
})
@@ -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<CloudPushAccess> {
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<boolean> {
// 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
}
}
@@ -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()
@@ -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 () => {
@@ -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(() => () => {}),
@@ -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(() => () => {}),
@@ -44,6 +44,7 @@ function stubApi(overrides: Partial<OrcaCloudWorkspaceApi> = {}): 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' })
),
@@ -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<OpenOutcome>>(),
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<unknown>>()
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()
})
})
@@ -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<boolean> {
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<boolean> {
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<boolean> {
})
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<void> {
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)
}
@@ -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<AppState, 'runtimeEnvironments'>
const hidden = (state: Pick<AppState, 'runtimeEnvironments'>) => [
...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds
]
expect(hidden(pairing())).toEqual([])
expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p'])
})
})
@@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime
import type { Repo } from '../../../../shared/repo-types'
type CloudScopeState = Pick<AppState, 'runtimeEnvironments'> &
Partial<Pick<AppState, 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId'>>
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<AppState, 'runtimeEnvironments'> &
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): {
@@ -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. */
@@ -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([])
})
})
@@ -14,7 +14,12 @@ const FATAL_CHECK_IDS: ReadonlySet<string> = 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.<command> 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)
}
@@ -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,
@@ -186,6 +186,7 @@ export type WorktreeSlice = {
pausedMs?: number
resumedAt?: number
vmEnvironmentId?: string
pairingHostId?: string
checkoutProgress?: PendingWorktreeCreation['checkoutProgress']
worktreeId?: string
heldChatMessage?: string
@@ -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<CloudWorkspaceResult<HostedGithubRepoAccessResponse>>
/**
* 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<CloudWorkspaceResult<GitHubInstallRights | null>>
/** 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',
+4 -2
View File
@@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) {
const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items)
const render = (collapsedGroups: Set<string>): 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))
}
}
+2 -1
View File
@@ -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
})