Merge branch 'cloud/integration' of https://github.com/nwparker/orca-private into cloud/enterprise-audit-log

# Conflicts:
#	cloud/apps/sandbox-control/src/index.ts
This commit is contained in:
Neil
2026-11-12 21:20:52 -08:00
33 changed files with 1078 additions and 713 deletions
+4 -3
View File
@@ -148,9 +148,10 @@ export type Env = {
/** Operator routes (/v1/internal/*): kill switch, drain, bindings. Secret, >= 32 chars; unset = no routes. */
OPERATOR_TOKEN?: string
/**
* Auth's bearer for POST /v1/internal/org-members/removed only (PropelAuth offboarding and
* Orca-side removals). Secret, >= 32 chars; lets auth push removals without holding the
* operator token. The operator token is still accepted on that route.
* Auth's bearer for its own pushes only: POST /v1/internal/org-members/removed (PropelAuth
* offboarding and Orca-side removals), POST /v1/internal/members/role-changed (a role changed in
* Orca or by a customer's identity provider) and deleting a team. Secret, >= 32 chars; lets auth
* push without holding the operator token. The operator token is still accepted on those routes.
*/
MEMBER_REMOVED_TOKEN?: string
/** `true` refuses every create/resume/rollover (static kill switch; the D1 flag is the runtime one). */
+6 -5
View File
@@ -1247,13 +1247,14 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
return await internalInstallUrl(env, deps, url)
}
if (url.pathname.startsWith('/v1/internal/')) {
// Auth's token (MEMBER_REMOVED_TOKEN) is good for its own routes only: the member-removed
// push and deleting a team. Never the operator routes.
// Auth's token (MEMBER_REMOVED_TOKEN) is good for its own routes only: the member-removed and
// role-changed pushes and deleting a team. Never the other operator routes.
const teamDelete = teamDeleteRoute(url.pathname, request.method)
// Append-only events from auth for the team audit log (api/audit-ingest.ts).
const auditIngest = url.pathname === '/v1/internal/audit-events' && request.method === 'POST'
const authOnly =
((url.pathname === '/v1/internal/org-members/removed' && request.method === 'POST') || teamDelete !== null || auditIngest) &&
(await isOperator(request, env.MEMBER_REMOVED_TOKEN))
const memberPush =
request.method === 'POST' && (url.pathname === '/v1/internal/org-members/removed' || url.pathname === '/v1/internal/members/role-changed')
const authOnly = (memberPush || teamDelete !== null || auditIngest) && (await isOperator(request, env.MEMBER_REMOVED_TOKEN))
if (!authOnly && !(await isOperator(request, env.OPERATOR_TOKEN))) {
return fail('not_found', 404)
}
@@ -189,9 +189,33 @@ describe('member-removed token', () => {
expect((await h.call('POST', '/v1/internal/org-members/removed', { bearer: MR, body })).status).toBe(200)
expect((await h.call('GET', '/v1/internal/status', { bearer: MR })).status).toBe(404)
expect((await h.call('POST', '/v1/internal/members/removed', { bearer: MR, body })).status).toBe(404)
expect((await h.call('GET', '/v1/internal/members/role-changed', { bearer: MR })).status).toBe(404)
// The operator token still works on the route; a short token is never accepted.
expect((await h.call('POST', '/v1/internal/org-members/removed', { bearer: 'o'.repeat(40), body })).status).toBe(200)
const weak = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40), MEMBER_REMOVED_TOKEN: 'short' })
expect((await weak.call('POST', '/v1/internal/org-members/removed', { bearer: 'short', body })).status).toBe(404)
})
it('also authorizes the role-changed push, which marks older tokens stale', async () => {
const MR = 'm'.repeat(40)
const h = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40), MEMBER_REMOVED_TOKEN: MR })
// An admin's desktop token from before a demotion made by their identity provider (SCIM).
const stale = await h.token('ada', 'acme', 'sandbox', { orgRole: 'admin' })
const { secret } = (await h.call('POST', '/v1/api-keys', { bearer: stale, body: { name: 'audit', scopes: ['audit:read', 'workspaces:read'] } })).body
h.advance(60_000)
const changed = await h.call('POST', '/v1/internal/members/role-changed', {
bearer: MR,
body: { accountId: 'org:acme', userId: 'ada', role: 'member', changedAt: h.now() }
})
expect(changed.status).toBe(200)
expect(changed.body).toEqual({ updated: 1 })
// Their admin-only scopes are gone, and the older token cannot write the admin role back.
expect((await h.call('GET', '/v1/audit', { bearer: secret })).status).toBe(403)
await h.call('GET', '/v1/me', { bearer: stale })
expect((await h.call('GET', '/v1/audit', { bearer: secret })).status).toBe(403)
// A malformed body is refused, and a short token is never accepted.
expect((await h.call('POST', '/v1/internal/members/role-changed', { bearer: MR, body: { accountId: 'org:acme', userId: 'ada', role: 'root' } })).status).toBe(400)
const weak = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40), MEMBER_REMOVED_TOKEN: 'short' })
expect((await weak.call('POST', '/v1/internal/members/role-changed', { bearer: 'short', body: { accountId: 'org:acme', userId: 'ada', role: 'member' } })).status).toBe(404)
})
})
+1 -1
View File
@@ -234,4 +234,4 @@ tokens) answer 404 there and fall back to the Checkout request's quantity / the
## Open decisions
1. Real Enterprise contact URL (placeholder `https://onorca.dev/contact-sales`). 2. When to enable Stripe Tax.
1. When to enable Stripe Tax. (Decided: the Enterprise contact URL is `https://orca.dev/enterprise`.)
@@ -118,13 +118,6 @@
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/workspace-card/metadata/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
"text": "sidebar",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx",
"kind": "jsx-text",
@@ -10,7 +10,7 @@ import {
import { PaymentIssueNotice } from '../PaymentIssueNotice'
import { PlanStatusBadge } from '../PlanStatusBadge'
import type { PlanBillingNotice } from '../PlanBillingSection'
import { seatCountLabel } from '../team/team-copy'
import { PlanSeatFacts } from '../team/TeamPlanSummary'
import { formatHours, formatHoursUsed, includedUsagePercent } from '../plan-billing-format'
import {
includedUsageTone,
@@ -56,9 +56,11 @@ export function isSubscribed(result: OrcaBillingStatusResult | null): boolean {
}
function CurrentPlanStrip({
entitlement
entitlement,
members
}: {
entitlement: OrcaBillingEntitlement
members: number
}): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
@@ -70,15 +72,6 @@ function CurrentPlanStrip({
<div className="space-y-2 rounded-xl border border-border bg-card p-4 shadow-xs">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{entitlement.planLabel}</span>
<span className="text-muted-foreground">
{t('auto.components.billing.plans.seatsPeriod', '{{value0}} · {{value1}}', {
value0: seatCountLabel(entitlement.seats, t),
value1:
entitlement.interval === 'year'
? t('auto.components.billing.annual', 'Annual')
: t('auto.components.billing.monthly', 'Monthly')
})}
</span>
<PlanStatusBadge entitlement={entitlement} />
</div>
<Progress
@@ -114,6 +107,8 @@ function CurrentPlanStrip({
</span>
</div>
{warning ? <p className="text-xs font-medium">{warning}</p> : null}
{/* Seat facts moved here from the Team dialog, which now only links to Plan & billing. */}
<PlanSeatFacts entitlement={entitlement} members={members} />
</div>
)
}
@@ -186,7 +181,7 @@ export function PlansView(props: PlansViewProps): React.JSX.Element {
busy={props.busy}
onUpdatePayment={props.onManage}
/>
<CurrentPlanStrip entitlement={entitlement} />
<CurrentPlanStrip entitlement={entitlement} members={props.seats} />
</>
) : isOrcaCloudTrial(entitlement) ? (
// The trial's strip: the plan cards right below are the way out of it (spec T1).
@@ -13,6 +13,7 @@ import { TeamPageView } from './TeamPageView'
import { createTeamFailureCopy, mutationFailureCopy } from './team-copy'
import { clearTeamPageArrival, closeTeamPage, useTeamPageState } from './team-page-store'
import { useOrgRoster } from './use-org-roster'
import { leaveTeamConfirmation } from './team-leave'
import { useTeamActions } from './use-team-actions'
import { useTeamDelete } from './use-team-delete'
import { TeamDeleteSection } from './TeamDeleteSection'
@@ -131,17 +132,7 @@ export function TeamPageBody({
return
}
const name = cloud?.activeOrgName ?? t('auto.components.billing.team.thisTeam', 'this team')
void confirm({
title: t('auto.components.billing.team.leaveConfirmTitle', 'Leave {{value0}}?', {
value0: name
}),
description: t(
'auto.components.billing.team.leaveConfirmBody',
'You lose access to the team’s cloud workspaces and secrets right away. An owner or admin can invite you back.'
),
confirmLabel: t('auto.components.billing.team.leaveCta', 'Leave team'),
confirmVariant: 'destructive'
}).then((accepted) => {
void confirm(leaveTeamConfirmation(name, t)).then((accepted) => {
if (!accepted) {
return
}
@@ -10,11 +10,10 @@ import {
} from '@/components/ui/select'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import type { OrcaOrgRole } from '../../../../../shared/orca-profiles'
import { formatCents } from '../plans/plans-pricing'
import {
MAX_INVITES_PER_BATCH,
mergeInviteCandidates,
@@ -23,7 +22,8 @@ import {
} from './parse-invite-emails'
import { roleDescription } from './team-copy'
import { assignableRoles, roleLabel } from './team-roles'
import { inviteSeatImpact, type InviteSeatImpact } from './team-seat-math'
import { inviteSeatImpactLine } from './team-invite-impact-copy'
import { inviteSeatImpact } from './team-seat-math'
export type InviteBatchOutcome = { failed: { email: string; message: string }[] }
@@ -38,74 +38,6 @@ export type TeamInviteComposerProps = {
inputRef?: React.Ref<HTMLInputElement>
}
function impactCopy(
impact: InviteSeatImpact,
candidates: InviteCandidate[],
locale: string,
t: Translate
): string {
const count = candidates.length
if (count === 0) {
return t(
'auto.components.billing.team.inviteHint',
'Paste a list: commas, spaces and new lines all work. Invites don’t take a seat until accepted.'
)
}
switch (impact.kind) {
case 'no-plan':
return t(
'auto.components.billing.team.inviteNoPlan',
'Invites are free. Seats are billed once your team picks a plan.'
)
case 'covered':
return count === 1
? t(
'auto.components.billing.team.inviteCoveredOne',
'Covered: uses a seat you already paid for this period.'
)
: t(
'auto.components.billing.team.inviteCoveredMany',
'Covered: these use seats you already paid for this period.'
)
case 'adds-seats-unpriced':
return t(
'auto.components.billing.team.inviteAddsUnpriced',
'Each accepted invite above your paid seats adds a seat, charged now and prorated.'
)
case 'adds-seats': {
const prorated = formatCents(impact.proratedCentsEach, locale)
const price = formatCents(impact.perSeatPeriodCents, locale)
const then =
impact.interval === 'year'
? t('auto.components.billing.team.totalPerYear', '{{total}} per year', { total: price })
: t('auto.components.billing.team.totalPerMonth', '{{total}} per month', { total: price })
if (count === 1) {
const first = candidates[0]!.email.split('@')[0] ?? candidates[0]!.email
return t(
'auto.components.billing.team.inviteAddsOne2',
'When {{value0}} accepts, they add a seat: about {{value1}} prorated, then {{value2}}.',
{ value0: first, value1: prorated, value2: then }
)
}
const covered =
impact.coveredSeats > 0
? ` ${t(
'auto.components.billing.team.inviteCoveredPart',
'The first {{value0}} use seats already paid.',
{ value0: String(impact.coveredSeats) }
)}`
: ''
return (
t(
'auto.components.billing.team.inviteAddsMany3',
'{{value0}} of these add a seat each when accepted: about {{value1}} each now (prorated), then {{value2}} each.',
{ value0: String(impact.addedSeats), value1: prorated, value2: then }
) + covered
)
}
}
}
/** Multi-email invite field: paste a list, review the chips, pick a role, send. */
export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.Element {
const t = useTranslate()
@@ -133,8 +65,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
const pendingDraft = draft.trim()
? mergeInviteCandidates(candidates, splitInviteText(draft))
: candidates
// The button counts what Send would send (including a typed, unseparated address); the hint
// describes committed chips only, so it doesn't change on every keystroke.
// The button and the seat line both count what Send would send, including a typed,
// unseparated address, so the seat impact always matches the invites about to go out.
const valid = pendingDraft.filter((candidate) => candidate.valid)
const committedValid = candidates.filter((candidate) => candidate.valid)
const invalidCount = candidates.length - committedValid.length
@@ -143,7 +75,7 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
entitlement: props.entitlement,
members: props.members,
pendingInvites: props.pendingInvites,
newInvites: committedValid.length,
newInvites: valid.length,
now: props.now
})
@@ -339,8 +271,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El
</div>
</div>
<p id={hintId} className="text-xs text-muted-foreground text-pretty">
{impactCopy(impact, committedValid, locale, t)}
{paymentFailed && committedValid.length > 0
{inviteSeatImpactLine(impact, valid.length, locale, t)}
{paymentFailed && valid.length > 0
? ` ${t(
'auto.components.billing.team.invitePaymentFailed2',
'New seats are charged once the failed payment is fixed.'
@@ -44,3 +44,26 @@ it('marks a busy row with aria-busy and a spinner, without dimming it', () => {
expect(busy.querySelector('.animate-spin')).not.toBeNull()
expect(idle.querySelector('.animate-spin')).toBeNull()
})
it('offers Leave from the ⋯ on your own row only', () => {
const { container } = render(
<TeamMemberList
rows={teamRows({ ...roster, viewerRole: 'member', canManageMembers: false }, 'u-grace')}
viewerRole="member"
rowStates={{}}
canLeave
now={Date.UTC(2026, 10, 18)}
onChangeRole={noop}
onMakeOwner={noop}
onRemove={noop}
onResend={noop}
onRevoke={noop}
onLeave={noop}
/>
)
const self = container.querySelector('[data-team-row="member:u-grace"]')!
const other = container.querySelector('[data-team-row="member:u-neil"]')!
expect(self.querySelector('button[aria-label^="Actions for"]')).not.toBeNull()
// A plain member has nothing to do on someone else's row.
expect(other.querySelector('button[aria-label^="Actions for"]')).toBeNull()
})
@@ -40,6 +40,8 @@ export type TeamMemberListProps = {
rowStates: Readonly<Record<string, TeamRowState>>
/** The viewer may leave (another owner exists, or they are not an owner). */
canLeave: boolean
/** Shown under the disabled "Leave team…" item when `canLeave` is false: what to do instead. */
leaveBlockedReason?: string | null
now: number
onChangeRole: (member: OrcaOrgMember, role: OrcaOrgRole) => void
onMakeOwner: (member: OrcaOrgMember) => void
@@ -199,6 +201,18 @@ function RowMenu({
{t('auto.components.billing.team.leave', 'Leave team…')}
</DropdownMenuItem>
)
if (!props.canLeave && props.leaveBlockedReason) {
// A disabled item alone doesn't say why; the last owner needs the way out spelled out.
items.push(
<p
key="leave-why"
data-team-leave-blocked=""
className="px-2 pb-1.5 text-xs text-muted-foreground text-pretty"
>
{props.leaveBlockedReason}
</p>
)
}
}
if (items.length === 0) {
// Keeps the role column aligned with rows that have a menu.
@@ -219,7 +233,7 @@ function RowMenu({
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className={row.self && !props.canLeave ? 'w-64' : 'w-48'}>
{items}
</DropdownMenuContent>
</DropdownMenu>
@@ -1,4 +1,8 @@
import { useEffect } from 'react'
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { ConfirmationDialogProvider } from '@/components/confirmation-dialog'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { useTranslate } from '@/i18n/use-translate'
import { Dialog } from '@/components/ui/dialog'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
@@ -6,6 +10,7 @@ import { sampleTeam } from '../plan-billing-fixtures'
import { TeamDialogContent } from './TeamDialogHost'
import { TeamPageView, type TeamPageViewProps } from './TeamPageView'
import { TeamDeleteSection, type TeamDeleteCheck } from './TeamDeleteSection'
import { leaveTeamConfirmation } from './team-leave'
// Fixtures render offline: a self-contained stand-in for a provider profile picture.
const ADA_PICTURE =
@@ -225,27 +230,51 @@ export const createTeamUnsupported = defineFixture({
)
})
/** Pasted list mid-composition: valid chips, one invalid, and the seat impact line. */
/** Types into the invite field; the trailing separator commits the chips. */
async function typeInvites(text: string): Promise<void> {
// The dialog portals to <body>, outside the fixture frame.
const input = document.querySelector<HTMLInputElement>('input[inputmode="email"]')
if (!input) {
return
}
input.focus()
// React listens for native input events.
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setValue?.call(input, text)
input.dispatchEvent(new Event('input', { bubbles: true }))
await new Promise((resolve) => setTimeout(resolve, 100))
}
/** Will add seats: 4 paid, 4 members and 2 pending, so both new invites add a seat. */
export const invitePasted = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 4 } }} />,
interact: async () => {
// The dialog portals to <body>, outside the fixture frame.
const input = document.querySelector<HTMLInputElement>('input[inputmode="email"]')
if (!input) {
return
}
input.focus()
// React listens for native input events; the trailing separator commits the chips.
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setValue?.call(input, 'jordan@acme.dev, kim@acme.dev, not-an-email ')
input.dispatchEvent(new Event('input', { bubbles: true }))
await new Promise((resolve) => setTimeout(resolve, 100))
}
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev, not-an-email ')
})
/** Short roster so the Enterprise SSO row and Leave sit inside the frame. */
export const ssoAndLeave = defineFixture({
/** Seats free: 8 paid, 6 taken (members plus pending), so one invite adds nothing. */
export const inviteSeatsFree = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, seats: 8 } }} />,
interact: () => typeInvites('jordan@acme.dev ')
})
/** Billing unknown (disabled in this build, or it failed): the rule without counts or prices. */
export const inviteBillingUnknown = defineFixture({
width,
render: () => <Page billing={{ status: 'unconfigured' }} />,
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
})
/** Billing sent no price: seats are still counted, no price is shown. */
export const inviteNoPrice = defineFixture({
width,
render: () => <Page billing={{ status: 'ok', entitlement: { ...team, offers: null } }} />,
interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ')
})
/** Short roster so the Enterprise row sits inside the frame. */
export const ssoRow = defineFixture({
width,
render: () => (
<Page
@@ -261,7 +290,41 @@ export const ssoAndLeave = defineFixture({
)
})
// TEAM-DELETE: Settings › Team (inline), owner. The danger zone sits after "Leave <team>".
/** The only owner opens the ⋯ on their own row: Leave is off and says what to do instead. */
export const lastOwnerLeaveBlocked = defineFixture({
width,
render: () => <Page />,
interact: async () => {
const trigger = document.querySelector<HTMLElement>(
'[data-team-row="member:u-neil"] button[aria-haspopup="menu"]'
)
trigger?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
await new Promise((resolve) => setTimeout(resolve, 150))
}
})
/** Choosing "Leave team…" from the ⋯ menu on your own row. */
function OpenLeaveConfirm(): null {
const t = useTranslate()
const confirm = useConfirmationDialog()
useEffect(() => {
void confirm(leaveTeamConfirmation('Orca Staging Eval Team', t))
}, [confirm, t])
return null
}
export const leaveConfirm = defineFixture({
width: 560,
render: () => (
<div className="h-[300px]">
<ConfirmationDialogProvider>
<OpenLeaveConfirm />
</ConfirmationDialogProvider>
</div>
)
})
// TEAM-DELETE: Settings › Team (inline), owner. The danger zone sits in the bottom card.
function DeletePage({ check }: { check: TeamDeleteCheck }): React.JSX.Element {
// Settings › Team renders the page inline, not in the dialog, so the whole page shows.
return (
@@ -1,11 +1,10 @@
import { useEffect, useId, useRef } from 'react'
import { ExternalLink, LogOut, RefreshCw } from 'lucide-react'
import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type { OrcaBillingStatusResult } from '../../../../../shared/orca-billing'
import type {
@@ -17,9 +16,12 @@ import { TeamMark } from './TeamAvatar'
import { TeamCreateView, type TeamCreateViewProps } from './TeamCreateView'
import { TeamInviteComposer, type InviteBatchOutcome } from './TeamInviteComposer'
import { TeamMemberList, type TeamRowState } from './TeamMemberList'
import { TeamBillingBlock, TeamCheckoutCelebration } from './TeamPlanSummary'
import { PaymentIssueNotice } from '../PaymentIssueNotice'
import { TeamCheckoutCelebration, TeamUpgradeCard } from './TeamPlanSummary'
import { memberCountLabel } from './team-copy'
import { leaveBlockedReason } from './team-leave'
import { otherOwnerCount, teamRows } from './team-roster-rows'
import { isActivePlan } from './team-seat-math'
import type { TeamPageArrival } from './team-page-store'
export type TeamPageViewProps = {
@@ -50,7 +52,7 @@ export type TeamPageViewProps = {
onManageBilling: () => void
onCreateWorkspace: () => void
onDismissArrival: () => void
/** Opens the Enterprise contact page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */
/** Opens the Enterprise page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */
onContactSales: (url: string) => void
/** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */
inline?: { onOpenPlanBilling: () => void }
@@ -58,40 +60,6 @@ export type TeamPageViewProps = {
dangerZone?: React.ReactNode
}
function viewerRoleSentence(role: OrcaOrgRole, t: Translate): string {
switch (role) {
case 'owner':
return t('auto.components.billing.team.youAreOwner', 'You’re an owner')
case 'admin':
return t('auto.components.billing.team.youAreAdmin', 'You’re an admin')
case 'member':
return t('auto.components.billing.team.youAreMember', 'You’re a member')
}
}
/** Mirrors the plan card (title + badge, three stats, a two-line note) so loading doesn't jump. */
function PlanSkeleton(): React.JSX.Element {
return (
<div aria-hidden className="space-y-3 rounded-xl border border-border bg-card p-4 shadow-xs">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-12" />
<Skeleton shape="circle" className="h-5 w-14" />
</div>
<div className="grid grid-cols-3 gap-4">
{[0, 1, 2].map((index) => (
<div key={index} className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3 w-24" />
</div>
))}
</div>
<Skeleton className="h-3 w-11/12" />
<Skeleton className="h-3 w-2/3" />
</div>
)
}
function SkeletonRows(): React.JSX.Element {
return (
<div
@@ -128,7 +96,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
document.querySelector<HTMLElement>('[data-team-page-heading]')?.focus()
}
}, [hasRoster, inline])
const leaveTitleId = useId()
if (!props.org) {
return (
@@ -170,8 +137,7 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
? t('auto.components.billing.team.invitedCount', '{{count}} invited', {
count: roster.pendingInvites.length
})
: null,
viewerRoleSentence(viewerRole, t)
: null
]
.filter(Boolean)
.join(' · ')
@@ -180,6 +146,20 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
: t('auto.components.billing.team.loadFailedTitle', 'Couldn’t load team')}
</p>
</div>
{!inline && entitlement?.canManageBilling ? (
// Seats, prices and invoices live in Plan & billing; one quiet way there from here.
// mr-6 clears the dialog's close button, which sits in this corner.
<Button
type="button"
variant="ghost"
size="xs"
className="mr-6 shrink-0"
onClick={props.onSeePlans}
>
<CreditCard />
{t('auto.components.billing.team.planBillingLink', 'Plan & billing')}
</Button>
) : null}
</header>
{props.arrival?.kind === 'checkout-success' && entitlement ? (
@@ -211,15 +191,16 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
</button>
</p>
) : entitlement ? (
<TeamBillingBlock
entitlement={entitlement}
members={roster?.members.length ?? 0}
busy={props.billingBusy}
onManageBilling={props.onManageBilling}
onSeePlans={props.onSeePlans}
/>
) : props.billing === null ? (
<PlanSkeleton />
<>
<PaymentIssueNotice
entitlement={entitlement}
busy={props.billingBusy}
onUpdatePayment={props.onManageBilling}
/>
{isActivePlan(entitlement) ? null : (
<TeamUpgradeCard entitlement={entitlement} onSeePlans={props.onSeePlans} />
)}
</>
) : null}
{props.roster === null ? (
@@ -270,6 +251,10 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
viewerRole={viewerRole}
rowStates={props.rowStates}
canLeave={canLeave}
leaveBlockedReason={leaveBlockedReason(
{ canLeave, canDeleteTeam: Boolean(props.dangerZone) },
t
)}
now={props.now}
// Promoting to owner hands over billing control, so it always confirms first.
onChangeRole={(member, role) =>
@@ -313,22 +298,13 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
) : null}
</section>
{(roster?.canManageMembers && enterprise) || selfRow ? (
{(roster?.canManageMembers && enterprise) || props.dangerZone ? (
<div className="divide-y divide-border/60 rounded-xl border border-border">
{roster?.canManageMembers && enterprise ? (
<section aria-labelledby={ssoTitleId} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-[14rem] flex-1 space-y-0.5">
<h3 id={ssoTitleId} className="text-sm font-medium">
{t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')}
</h3>
<p className="text-xs text-muted-foreground text-pretty">
{t(
'auto.components.billing.team.ssoBody',
'SAML SSO and SCIM user provisioning are part of {{value0}}.',
{ value0: enterprise.label }
)}
</p>
</div>
<h3 id={ssoTitleId} className="min-w-[14rem] flex-1 text-sm font-medium">
{t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')}
</h3>
<OpenExternalSplitButton
variant="outline"
size="sm"
@@ -340,57 +316,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
</OpenExternalSplitButton>
</section>
) : null}
{selfRow ? (
<section
aria-labelledby={leaveTitleId}
className="flex flex-wrap items-center gap-3 p-4"
>
<div className="min-w-[14rem] flex-1 space-y-0.5">
<h3 id={leaveTitleId} className="text-sm font-medium">
{t('auto.components.billing.team.leaveTitle', 'Leave {{value0}}', {
value0: props.org.name
})}
</h3>
<p className="text-xs text-muted-foreground text-pretty">
{canLeave
? t(
'auto.components.billing.team.leaveBody2',
'You lose access to the team’s cloud workspaces and secrets. An owner or admin can invite you back.'
)
: props.dangerZone
? t(
'auto.components.billing.team.leaveLastOwnerOrDelete',
'You’re the only owner. Make someone else an owner first, or delete the team.'
)
: t(
'auto.components.billing.team.leaveLastOwner',
'You’re the only owner. Make someone else an owner first.'
)}
</p>
</div>
<SettingsDisabledControlTooltip
reason={
canLeave
? undefined
: t(
'auto.components.billing.team.leaveNeedsOwner',
'Make someone else an owner first'
)
}
>
<Button
type="button"
variant="destructive-chip"
size="sm"
disabled={!canLeave}
onClick={props.onLeave}
>
<LogOut />
{t('auto.components.billing.team.leaveCta', 'Leave team')}
</Button>
</SettingsDisabledControlTooltip>
</section>
) : null}
{props.dangerZone}
</div>
) : null}
@@ -1,15 +1,11 @@
import { useEffect, useRef } from 'react'
import { ArrowRight, Check, Cloud, ExternalLink, UserPlus, X } from 'lucide-react'
import { ArrowRight, Check, Cloud, UserPlus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import { PaymentIssueNotice } from '../PaymentIssueNotice'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { billingPortalLink } from '../billing-link'
import { PlanStatusBadge } from '../PlanStatusBadge'
import { formatCents, perSeatMonthlyCents } from '../plans/plans-pricing'
import { seatCountLabel } from './team-copy'
import { isActivePlan, nextInvoiceEstimate } from './team-seat-math'
import { nextInvoiceEstimate } from './team-seat-math'
/** One-time confirmation after Checkout: tasteful, dismissible, with the obvious next steps. */
export function TeamCheckoutCelebration({
@@ -106,19 +102,17 @@ function Stat({
)
}
/** Seats in use vs paid, the plan, and the next invoice (an estimate before tax and overage). */
export function TeamSeatsCard({
/**
* The seat facts in Plan & billing: seats paid and in use, the per-seat price, the next invoice (an
* estimate before tax and overage) and the seat rule. Billing's numbers only; no price is shown
* when billing didn't send one.
*/
export function PlanSeatFacts({
entitlement,
members,
busy,
onManageBilling,
onSeePlans
members
}: {
entitlement: OrcaBillingEntitlement
members: number
busy: boolean
onManageBilling: () => void
onSeePlans: () => void
}): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
@@ -142,30 +136,12 @@ export function TeamSeatsCard({
: t('auto.components.billing.team.seatsInUse', '{{value0}} in use', {
value0: String(members)
})
const billedLabel =
entitlement.interval === 'year'
? t('auto.components.billing.annual', 'Annual')
: t('auto.components.billing.monthly', 'Monthly')
return (
<div className="space-y-3 rounded-xl border border-border bg-card p-4 shadow-xs">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{entitlement.planLabel}</p>
<PlanStatusBadge entitlement={entitlement} />
<span className="flex-1" />
{entitlement.canManageBilling ? (
<>
<Button type="button" variant="outline" size="sm" onClick={onSeePlans}>
{t('auto.components.billing.team.usage', 'Usage & plan')}
</Button>
<OpenExternalSplitButton
variant="outline"
size="sm"
disabled={busy}
link={billingPortalLink}
onClick={onManageBilling}
>
{t('auto.components.billing.manageBilling', 'Manage billing')}
<ExternalLink />
</OpenExternalSplitButton>
</>
) : null}
</div>
<div className="space-y-2 border-t border-border/60 pt-3">
<dl className="grid grid-cols-3 gap-4">
<Stat
label={t('auto.components.billing.team.seatsPaid', 'Seats paid')}
@@ -173,19 +149,15 @@ export function TeamSeatsCard({
detail={seatsDetail}
/>
<Stat
label={t('auto.components.billing.team.billing', 'Billing')}
label={t('auto.components.billing.team.perSeat', 'Per seat')}
value={
entitlement.interval === 'year'
? t('auto.components.billing.annual', 'Annual')
: t('auto.components.billing.monthly', 'Monthly')
}
detail={
seatMonthlyCents === null
? null
: t('auto.components.billing.team.perSeatPerMonth', '{{value0}} per seat per month', {
? billedLabel
: t('auto.components.billing.team.perSeatMonthValue', '{{value0}}/mo', {
value0: formatCents(seatMonthlyCents, locale)
})
}
detail={seatMonthlyCents === null ? null : billedLabel}
/>
{invoice ? (
<Stat
@@ -205,8 +177,8 @@ export function TeamSeatsCard({
</dl>
<p className="text-xs text-muted-foreground text-pretty">
{t(
'auto.components.billing.team.seatRules',
'New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal, and re-adding someone this period is free.'
'auto.components.billing.team.seatRulesShort',
'New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.'
)}
</p>
</div>
@@ -269,34 +241,3 @@ export function TeamUpgradeCard({
</section>
)
}
export function TeamBillingBlock({
entitlement,
members,
busy,
onManageBilling,
onSeePlans
}: {
entitlement: OrcaBillingEntitlement
members: number
busy: boolean
onManageBilling: () => void
onSeePlans: () => void
}): React.JSX.Element {
return (
<div className="space-y-3">
<PaymentIssueNotice entitlement={entitlement} busy={busy} onUpdatePayment={onManageBilling} />
{isActivePlan(entitlement) ? (
<TeamSeatsCard
entitlement={entitlement}
members={members}
busy={busy}
onManageBilling={onManageBilling}
onSeePlans={onSeePlans}
/>
) : (
<TeamUpgradeCard entitlement={entitlement} onSeePlans={onSeePlans} />
)}
</div>
)
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
import type { Translate } from '@/i18n/use-translate'
import { sampleTeam } from '../plan-billing-fixtures'
import { formatCents } from '../plans/plans-pricing'
import { inviteSeatImpactLine } from './team-invite-impact-copy'
import { inviteSeatImpact } from './team-seat-math'
const DAY = 86_400_000
// Mirrors i18next's `_one` plural pick so both forms are checked.
const t: Translate = (_key, fallback, options) => {
const one = options?.count === 1 ? options.defaultValue_one : undefined
const text = typeof one === 'string' ? one : fallback
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? ''))
}
function team(overrides: Partial<OrcaBillingEntitlement> = {}): OrcaBillingEntitlement {
return {
...sampleTeam,
status: 'active',
interval: 'month',
seats: 3,
seatChange: null,
cancelAtPeriodEnd: false,
period: { start: 0, end: 30 * DAY },
...overrides
}
}
function line(
entitlement: OrcaBillingEntitlement | null,
counts: { members: number; pendingInvites: number; newInvites: number }
): string {
const impact = inviteSeatImpact({ entitlement, ...counts, now: 0 })
return inviteSeatImpactLine(impact, counts.newInvites, 'en-US', t)
}
const monthly = sampleTeam.offers?.team.monthlyPriceCentsPerSeat ?? 0
describe('invite seat impact line', () => {
it('says how many seats are open before anything is typed', () => {
expect(line(team({ seats: 5 }), { members: 2, pendingInvites: 1, newInvites: 0 })).toBe(
'Each invite uses a seat once accepted. You have 2 open seats.'
)
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 0 })).toBe(
'Each invite uses a seat once accepted. You have no open seats.'
)
})
it('adds nothing while the invites fit in open seats', () => {
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 1 })).toBe(
'Each invite uses a seat once accepted. You have 1 open seat, so no seats will be added.'
)
})
it('prices the added seats from billing, per period, prorated', () => {
const total = formatCents(2 * monthly, 'en-US')
expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
`Each invite uses a seat once accepted. You have no open seats, so 2 more seats (${total}/mo, prorated) will be added.`
)
})
it('counts open seats first, then pluralizes the added ones apart', () => {
const one = formatCents(monthly, 'en-US')
expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
`Each invite uses a seat once accepted. You have 1 open seat, so 1 more seat (${one}/mo, prorated) will be added.`
)
})
it('names the yearly price on annual plans', () => {
expect(
line(team({ seats: 1, interval: 'year' }), { members: 1, pendingInvites: 0, newInvites: 1 })
).toMatch(/\/yr, prorated\) will be added\.$/)
})
it('never shows a price billing did not send', () => {
expect(
line(team({ seats: 3, offers: null }), { members: 3, pendingInvites: 0, newInvites: 2 })
).toBe(
'Each invite uses a seat once accepted. You have no open seats, so 2 more seats will be added, prorated.'
)
})
it('keeps to the rule when billing is off or unreachable', () => {
expect(line(null, { members: 3, pendingInvites: 0, newInvites: 2 })).toBe(
'Each invite uses a seat once accepted.'
)
})
it('says invites are free before the team has a plan', () => {
expect(
line(team({ plan: null, status: 'none' }), { members: 1, pendingInvites: 0, newInvites: 1 })
).toBe('Invites are free. Seats are billed once your team picks a plan.')
})
})
@@ -0,0 +1,86 @@
// The one line under "Invite teammates" that says what the invites do to seats. Billing's own
// numbers only (ENTITLEMENTS-C): no price is shown when billing didn't send one.
import type { Translate } from '@/i18n/use-translate'
import { formatCents } from '../plans/plans-pricing'
import type { InviteSeatImpact } from './team-seat-math'
/** "You have N open seats", its own key so the seat count and the added count pluralize apart. */
function openSeatsClause(open: number, t: Translate): string {
return open === 0
? t('auto.components.billing.team.openSeatsNone', 'You have no open seats')
: t('auto.components.billing.team.openSeatsClause', 'You have {{count}} open seats', {
count: open,
defaultValue_one: 'You have {{count}} open seat'
})
}
export function inviteSeatImpactLine(
impact: InviteSeatImpact,
invites: number,
locale: string,
t: Translate
): string {
switch (impact.kind) {
case 'unknown':
return t(
'auto.components.billing.team.inviteSeatUnknown',
'Each invite uses a seat once accepted.'
)
case 'no-plan':
return t(
'auto.components.billing.team.inviteNoPlan',
'Invites are free. Seats are billed once your team picks a plan.'
)
case 'covered':
return invites === 0
? t(
'auto.components.billing.team.inviteSeatOpen',
'Each invite uses a seat once accepted. {{openSeats}}.',
{
openSeats: openSeatsClause(impact.freeSeats, t)
}
)
: t(
'auto.components.billing.team.inviteSeatCovered',
'Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.',
{ openSeats: openSeatsClause(impact.freeSeats, t) }
)
case 'adds-seats-unpriced':
return t(
'auto.components.billing.team.inviteSeatAddsUnpriced',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.'
}
)
case 'adds-seats': {
const price = formatCents(impact.addedSeats * impact.perSeatPeriodCents, locale)
return impact.interval === 'year'
? t(
'auto.components.billing.team.inviteSeatAddsYear',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
price,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.'
}
)
: t(
'auto.components.billing.team.inviteSeatAddsMonth',
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.',
{
openSeats: openSeatsClause(impact.freeSeats, t),
count: impact.addedSeats,
price,
defaultValue_one:
'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.'
}
)
}
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { Translate } from '@/i18n/use-translate'
import { leaveBlockedReason, leaveTeamConfirmation } from './team-leave'
const t: Translate = (_key, fallback, options) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? ''))
describe('leaveTeamConfirmation', () => {
it('names the team and carries what leaving costs', () => {
expect(leaveTeamConfirmation('Orca Staging Eval Team', t)).toEqual({
title: 'Leave Orca Staging Eval Team?',
description:
'You lose access to the team’s cloud workspaces and secrets. An owner or admin can invite you back.',
confirmLabel: 'Leave team',
confirmVariant: 'destructive',
initialFocus: 'cancel'
})
})
})
describe('leaveBlockedReason', () => {
it('lets anyone leave who is not the last owner', () => {
expect(leaveBlockedReason({ canLeave: true, canDeleteTeam: true }, t)).toBeNull()
})
it('blocks the last owner with the way out', () => {
expect(leaveBlockedReason({ canLeave: false, canDeleteTeam: false }, t)).toBe(
'You’re the only owner. Make someone else an owner first.'
)
expect(leaveBlockedReason({ canLeave: false, canDeleteTeam: true }, t)).toBe(
'You’re the only owner. Make someone else an owner first, or delete the team.'
)
})
})
@@ -0,0 +1,42 @@
// Leaving a team: the confirmation the ⋯ menu on your own row opens, and why it can be blocked.
// The Team page has no separate "Leave team" row; the menu is the one way in.
import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context'
import type { Translate } from '@/i18n/use-translate'
export function leaveTeamConfirmation(teamName: string, t: Translate): ConfirmationDialogOptions {
return {
title: t('auto.components.billing.team.leaveConfirmTitle', 'Leave {{value0}}?', {
value0: teamName
}),
description: t(
'auto.components.billing.team.leaveBody2',
'You lose access to the team’s cloud workspaces and secrets. An owner or admin can invite you back.'
),
confirmLabel: t('auto.components.billing.team.leaveCta', 'Leave team'),
confirmVariant: 'destructive',
// Leaving is the destructive choice; Enter on open must not take it.
initialFocus: 'cancel'
}
}
/**
* Why the viewer can't leave, or null when they can. The last owner is blocked outright (never a
* confirm that then fails): someone else has to own the team first, or the team is deleted.
*/
export function leaveBlockedReason(
args: { canLeave: boolean; canDeleteTeam: boolean },
t: Translate
): string | null {
if (args.canLeave) {
return null
}
return args.canDeleteTeam
? t(
'auto.components.billing.team.leaveLastOwnerOrDelete',
'You’re the only owner. Make someone else an owner first, or delete the team.'
)
: t(
'auto.components.billing.team.leaveLastOwner',
'You’re the only owner. Make someone else an owner first.'
)
}
@@ -88,7 +88,7 @@ describe('inviteSeatImpact without prices', () => {
newInvites: 2,
now: 0
})
).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2 })
).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2, freeSeats: 0 })
})
})
@@ -48,13 +48,17 @@ export function proratedSeatCents(
}
export type InviteSeatImpact =
/** Billing didn't answer (disabled in this build, signed out or failed): no seat facts to show. */
| { kind: 'unknown' }
| { kind: 'no-plan' }
/** Every new invite fits in seats already paid this period. */
| { kind: 'covered'; freeSeats: number }
/** Seats will be added but the price or period isn't known (older billing service). */
| { kind: 'adds-seats-unpriced'; addedSeats: number }
| { kind: 'adds-seats-unpriced'; addedSeats: number; freeSeats: number }
| {
kind: 'adds-seats'
/** Paid seats not taken by a member or a pending invite. */
freeSeats: number
/** How many of the new invites would add a seat when accepted. */
addedSeats: number
/** Seats already paid but unused that the first acceptances take for free. */
@@ -76,7 +80,10 @@ export function inviteSeatImpact(args: {
now: number
}): InviteSeatImpact {
const { entitlement } = args
if (!entitlement || !isActivePlan(entitlement)) {
if (!entitlement) {
return { kind: 'unknown' }
}
if (!isActivePlan(entitlement)) {
return { kind: 'no-plan' }
}
const pricing = seatPricing(entitlement)
@@ -87,10 +94,11 @@ export function inviteSeatImpact(args: {
return { kind: 'covered', freeSeats }
}
if (!pricing || !entitlement.period) {
return { kind: 'adds-seats-unpriced', addedSeats }
return { kind: 'adds-seats-unpriced', addedSeats, freeSeats }
}
return {
kind: 'adds-seats',
freeSeats,
addedSeats,
coveredSeats: Math.min(freeSeats, args.newInvites),
proratedCentsEach: proratedSeatCents(pricing, entitlement.period, args.now),
@@ -0,0 +1,93 @@
// AGENT-PICKER-WIDTH: the composer's Agent menu is as wide as its field in every state. Neil's case:
// Project "~" (a folder, not a git repository) with Run on = Orca Cloud, which used to fall back to
// the compact 256 px menu under the full-width field.
import React from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { AgentLaunchPicker } from '@/components/agent/AgentLaunchPicker'
import type { AgentComboboxSurfaceRows } from '@/components/agent/agent-combobox-surface-items'
import { orcaCloudAgentMenuModel } from '@/lib/orca-cloud/orca-cloud-agent-picker'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
import { NewWorkspaceComposerAgentSection } from './NewWorkspaceComposerAgentSection'
const noop = (): void => {}
const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 80))
const model = orcaCloudAgentMenuModel({ defaultTuiAgent: 'claude', disabledTuiAgents: [] })
const STATUS: ReadonlyMap<TuiAgent, string> = new Map<TuiAgent, string>([
['codex', 'Not set up'],
['gemini', 'Not set up'],
['cursor', 'Not set up']
])
const rows: AgentComboboxSurfaceRows = {
model,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the picker reads only selection.cli.
selection: { cli: false } as AgentComboboxSurfaceRows['selection'],
onPick: noop,
statusLabels: STATUS
}
function Context({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="h-[520px] bg-background p-4">
<div className="w-[460px] space-y-2">
<p className="text-xs text-muted-foreground">Project: ~ (folder) · Run on: Orca Cloud</p>
{children}
</div>
</div>
)
}
const open = async (frame: HTMLElement): Promise<void> => {
// Radix menus open on pointerdown, not click.
frame
.querySelector<HTMLButtonElement>('button[aria-haspopup]')
?.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' })
)
await settle()
}
/** After: the composer's Agent section as the card renders it, menu open. */
export const homeFolderOrcaCloud = defineFixture({
width: 500,
render: () => (
<Context>
<NewWorkspaceComposerAgentSection
quickAgent="claude"
agentSurfaceRows={rows}
agentSetupDisabled={false}
onQuickAgentChange={noop}
onOpenAgentSettings={noop}
createDisabled={false}
onCreate={noop}
advancedOpen={false}
onToggleAdvanced={noop}
visibleQuickAgents={[]}
defaultTuiAgent="claude"
handleSetDefaultAgent={noop}
/>
</Context>
),
interact: open
})
/** Before (n39): the same menu at its compact width, for comparison. */
export const homeFolderOrcaCloudBefore = defineFixture({
width: 500,
render: () => (
<Context>
<AgentLaunchPicker
model={model}
agent="claude"
matchTriggerWidth={false}
emptyLabel="Blank terminal"
defaultAgent="claude"
onSetDefault={noop}
onSelectBlankTerminal={noop}
onPick={noop}
statusLabels={STATUS}
/>
</Context>
),
interact: open
})
@@ -15,9 +15,6 @@ type NewWorkspaceComposerAgentSectionProps = Pick<
| 'agentNotice'
| 'agentPickerDraft'
| 'agentSetupDisabled'
| 'selectedRepoIsGit'
| 'selectedProjectId'
| 'projectOptions'
| 'onQuickAgentChange'
| 'agentRows'
| 'onOpenAgentSettings'
@@ -40,9 +37,6 @@ export function NewWorkspaceComposerAgentSection({
agentNotice,
agentPickerDraft,
agentSetupDisabled,
selectedRepoIsGit,
selectedProjectId,
projectOptions,
onQuickAgentChange,
onOpenAgentSettings,
createDisabled,
@@ -90,11 +84,9 @@ export function NewWorkspaceComposerAgentSection({
value={agentPickerDraft}
cli={agentSurfaceRows?.selection.cli === true}
setupDisabled={agentSetupDisabled}
matchTriggerWidth={
selectedRepoIsGit &&
projectOptions?.find((option) => option.id === selectedProjectId)?.kind !==
'project-group'
}
// The field is always full width here, so its menu is too: a 256 px menu under it reads as
// broken (AGENT-PICKER-WIDTH: "~" folder or GitHub-only repo with Orca Cloud fell back to it).
matchTriggerWidth
onOpenAgentSettings={onOpenAgentSettings}
onTriggerEnter={createDisabled ? undefined : onCreate}
emptyLabel={t('auto.components.agent.AgentCombobox.986f946354', 'Blank terminal')}
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
// AGENT-PICKER-WIDTH: the composer's Agent menu takes its field's width in every state, including a
// folder project ("~") with Orca Cloud, which used to get the compact 256 px menu.
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AgentComboboxSurfaceRows } from '@/components/agent/agent-combobox-surface-items'
import { orcaCloudAgentMenuModel } from '@/lib/orca-cloud/orca-cloud-agent-picker'
import { NewWorkspaceComposerAgentSection } from './NewWorkspaceComposerAgentSection'
const noop = (): void => {}
afterEach(() => cleanup())
describe('composer Agent menu width', () => {
it('matches the field, with no project-kind condition', () => {
const rows: AgentComboboxSurfaceRows = {
model: orcaCloudAgentMenuModel({ defaultTuiAgent: 'claude', disabledTuiAgents: [] }),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the picker reads only selection.cli.
selection: { cli: false } as AgentComboboxSurfaceRows['selection'],
onPick: noop
}
const view = render(
<TooltipProvider>
<NewWorkspaceComposerAgentSection
quickAgent="claude"
agentSurfaceRows={rows}
agentSetupDisabled={false}
onQuickAgentChange={noop}
onOpenAgentSettings={noop}
createDisabled={false}
onCreate={noop}
advancedOpen={false}
onToggleAdvanced={noop}
visibleQuickAgents={[]}
defaultTuiAgent="claude"
handleSetDefaultAgent={noop}
/>
</TooltipProvider>
)
const trigger = view.container.querySelector<HTMLButtonElement>('button[aria-haspopup]')
expect(trigger).not.toBeNull()
act(() => {
fireEvent.pointerDown(trigger!, { button: 0, pointerType: 'mouse' })
})
const menu = document.querySelector('[data-slot="dropdown-menu-content"]')
expect(menu).not.toBeNull()
expect(menu!.className).toContain('w-(--radix-dropdown-menu-trigger-width)')
expect(menu!.className).not.toContain('min-w-64')
})
})
@@ -109,7 +109,7 @@ export const capabilityOff = defineFixture({
'claude',
'Claude',
false,
{ contactUrl: 'https://onorca.dev/contact-sales' }
{ contactUrl: 'https://orca.dev/enterprise' }
)
})
@@ -9,7 +9,7 @@ describe('CAP-GATE-DESKTOP copy', () => {
it('offers "contact us" only when [Contact us] is shown', () => {
const withLink = currentStepCopy(
{ current, action: 'capability-off', contactUrl: 'https://onorca.dev/contact-sales' },
{ current, action: 'capability-off', contactUrl: 'https://orca.dev/enterprise' },
t
)
expect(withLink.title).toBe('Orca Cloud isn’t turned on for your team yet')
@@ -1,103 +0,0 @@
import React from 'react'
import { WorktreeCardHostStateWord } from '../host/WorktreeCardHostMark'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import CacheTimer from '../activity/CacheTimer'
import { WorktreeHostContextBadge } from '../host/WorktreeHostContextBadge'
import { useWslHostDistro, WslHostMark } from '@/components/wsl-host-mark/WslHostMark'
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
import { TruncatedSidebarLabel } from '../../truncated-sidebar-label'
import {
WorktreeCardConflictBadge,
WorktreeCardFolderPath,
WorktreeCardMetaRepoBadge
} from './worktree-card-meta-items'
import type { WorktreeCardPresentation } from '../surface/worktree-card-presentation'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
export function WorktreeCardMetaRow({
card,
presentation
}: {
card: WorktreeCardController
presentation: WorktreeCardPresentation
}): React.JSX.Element {
const {
worktree,
repo,
identityDisplay,
isFolder,
newCardStyle,
branch,
detachedHeadDisplay,
conflictOperation,
cacheStartedAt,
cacheTtlMs
} = card
const {
showRepoBadgeInMetaRow,
showHostContextBadge,
contextLabel,
showIdentityInNewCard,
hasHoverDetails,
showBranch,
showDetachedHeadInMetaRow,
showConflictOperationBadge,
showMetaRowDetails,
detailsAndPorts
} = presentation
const wslDistro = useWslHostDistro(
showHostContextBadge ? getWorktreeExecutionHostId(worktree, repo) : null
)
return (
<div className="flex items-center gap-1.5 min-w-0" data-worktree-card-meta-row="">
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showRepoBadgeInMetaRow && repo && <WorktreeCardMetaRepoBadge repo={repo} />}
{showHostContextBadge &&
(wslDistro ? (
<WslHostMark distro={wslDistro} />
) : (
<WorktreeHostContextBadge label={contextLabel ?? ''} />
))}
<WorktreeCardHostStateWord worktreeId={worktree.id} />
{showIdentityInNewCard ? (
<TruncatedSidebarLabel
text={identityDisplay!}
className="text-[11px] text-muted-foreground leading-none"
tooltipEnabled={!hasHoverDetails}
/>
) : isFolder && !newCardStyle ? (
<WorktreeCardFolderPath path={worktree.path} />
) : showBranch ? (
<TruncatedSidebarLabel
text={branch}
className="text-[11px] text-muted-foreground leading-none"
// Why: whole-card details hover already shows full identity; a nested tooltip would compete for it.
tooltipEnabled={!hasHoverDetails}
/>
) : showDetachedHeadInMetaRow && detachedHeadDisplay ? (
<DetachedHeadBadge
display={detachedHeadDisplay}
label="sidebar"
side="right"
className="h-[16px]"
/>
) : null}
{showConflictOperationBadge && conflictOperation && (
<WorktreeCardConflictBadge operation={conflictOperation} />
)}
{cacheStartedAt != null && <CacheTimer startedAt={cacheStartedAt} ttlMs={cacheTtlMs} />}
</div>
{showMetaRowDetails && (
<div className="ml-auto flex shrink-0 items-center gap-1 pr-1.5">{detailsAndPorts}</div>
)}
</div>
)
}
@@ -1,159 +0,0 @@
import React from 'react'
import { shouldBeginWorktreeRename } from '../controller/worktree-card-model'
import type { WorktreeCardPresentation } from './worktree-card-presentation'
import {
WorktreeCardDeleteQuickAction,
WorktreeCardPrimaryBadge,
WorktreeCardPrimaryStar,
WorktreeCardRenameErrorButton,
WorktreeCardRepoChip,
WorktreeCardRuntimeHostMark,
WorktreeCardSparseBadge
} from '../identity/worktree-card-header-items'
import { WorktreeCardSshHostControl } from '../host/WorktreeCardSshHostControl'
import { WorktreeCardHostMark } from '../host/WorktreeCardHostMark'
import { CloudAgentHeldMark } from '@/components/orca-cloud/CloudAgentHeldMark'
import { useIsCloudHostRow } from '@/lib/orca-cloud/cloud-host-rows'
import { WorktreeTitleInlineRename } from '../identity/WorktreeTitleInlineRename'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
export function WorktreeCardHeader({
card,
presentation
}: {
card: WorktreeCardController
presentation: WorktreeCardPresentation
}): React.JSX.Element {
const {
worktree,
repo,
affiliateListMode,
renameRowKey,
compactCards,
newCardStyle,
parsedRepoHost,
sshTargetLabel,
sshStatus,
sshTargetRemoved,
sshOwnerEnvironmentId,
stopQuickActionPointerPropagation,
isRuntimeDisconnected,
runtimeHostLabel,
visibleCardTitle,
isDeleting,
showUnreadEmphasis,
setTitleRenaming,
handleRenameTitle,
renamingWorktreeId,
setRenamingWorktreeId,
titleRenaming,
handleOpenRenameErrorDialog,
isFolder,
handleWorkspaceQuickAction
} = card
// "primary" is a local checkout's role; on a cloud row it only means the default branch (design-squad).
const cloudRow = useIsCloudHostRow(worktree)
const {
showPinnedRepoIcon,
showInlineRepoBadge,
showHeaderActions,
showTitleRowPrimary,
showDeleteQuickAction,
showTitleRowIndicators,
titleRowIndicators,
titleWrapper
} = presentation
const hasHoverDetails = presentation.hasHoverDetails && !titleRenaming
return (
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{showPinnedRepoIcon && <WorktreeCardRepoChip repo={repo!} />}
<WorktreeCardHostMark
worktreeId={worktree.id}
onPointerDown={stopQuickActionPointerPropagation}
sshControl={
repo?.connectionId ? (
<WorktreeCardSshHostControl
targetId={repo.connectionId}
targetLabel={sshTargetLabel || repo.displayName}
status={sshStatus}
targetRemoved={sshTargetRemoved}
sshOwnerEnvironmentId={sshOwnerEnvironmentId}
iconOnly
onPointerDown={stopQuickActionPointerPropagation}
/>
) : undefined
}
runtimeMark={
!repo?.connectionId && parsedRepoHost?.kind === 'runtime' ? (
<WorktreeCardRuntimeHostMark
disconnected={isRuntimeDisconnected}
hostLabel={runtimeHostLabel}
/>
) : undefined
}
/>
{showInlineRepoBadge && <WorktreeCardRepoChip repo={repo!} />}
<WorktreeTitleInlineRename
displayName={visibleCardTitle}
disabled={isDeleting || affiliateListMode}
showUnreadEmphasis={showUnreadEmphasis}
dimReadTitle={newCardStyle}
className="text-[13px] leading-5"
editingClassName="flex-1"
titleWrapper={titleWrapper}
onEditingChange={affiliateListMode ? undefined : setTitleRenaming}
onRename={handleRenameTitle}
beginEditing={
!affiliateListMode &&
shouldBeginWorktreeRename(renamingWorktreeId, worktree.id, renameRowKey)
}
onBeginEditingConsumed={affiliateListMode ? undefined : () => setRenamingWorktreeId(null)}
/>
{/* N-03: the agent waits on its pane's strip (Orca Cloud): this mark is the signal away. */}
{!titleRenaming ? <CloudAgentHeldMark worktreeId={worktree.id} /> : null}
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 &&
!titleRenaming ? (
<WorktreeCardRenameErrorButton
onPointerDown={stopQuickActionPointerPropagation}
onOpen={handleOpenRenameErrorDialog}
/>
) : null}
{!compactCards && worktree.isMainWorktree && !isFolder && !cloudRow && (
<WorktreeCardPrimaryBadge card={card} hasHoverDetails={hasHoverDetails} />
)}
{worktree.isSparse && (
<WorktreeCardSparseBadge
card={card}
hasHoverDetails={hasHoverDetails}
directories={worktree.sparseDirectories}
/>
)}
{showTitleRowIndicators && titleRowIndicators}
</div>
{showHeaderActions && (
<div className="ml-auto flex shrink-0 items-center justify-center gap-1 pr-1.5">
{showTitleRowPrimary && !cloudRow && <WorktreeCardPrimaryStar />}
{showDeleteQuickAction && (
<WorktreeCardDeleteQuickAction
onPointerDown={stopQuickActionPointerPropagation}
onClick={handleWorkspaceQuickAction}
/>
)}
</div>
)}
</div>
)
}
@@ -1,80 +0,0 @@
import React from 'react'
import { cn } from '@/lib/class-names'
import { getWorkspaceAttachments } from '../../../../../../shared/workspace-attachments/workspace-attachments'
import { WorktreeCardHeader } from './worktree-card-header'
import { WorktreeCardMetaRow } from '../metadata/worktree-card-meta-row'
import { WorktreeCardIdentityHover } from '../details/worktree-card-details-items'
import type { WorktreeCardPresentation } from './worktree-card-presentation'
import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows'
import { WorktreeCardStatusLane } from './worktree-card-status-lane'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
export function WorktreeCardParentContent({
card,
presentation
}: {
card: WorktreeCardController
presentation: WorktreeCardPresentation
}): React.JSX.Element {
const { worktree, showInlineAgentList, titleRenaming } = card
const {
titleOnlyCard,
parentContentMarginLeft,
showCombinedStatusSlot,
showUnreadQuickAction,
hasHoverDetails,
hoverBranchName,
hoverWorkspaceTitle
} = presentation
const identityContent = (
<div
className="group/worktree-card flex w-full min-w-0 flex-col gap-1.5"
data-worktree-card-hover-trigger=""
>
<WorktreeCardHeader card={card} presentation={presentation} />
{presentation.hasMetaRow && <WorktreeCardMetaRow card={card} presentation={presentation} />}
</div>
)
// Why: status glyphs and agent rows own their tooltips; only identity content should open the larger details card.
const identityContentWithHover =
hasHoverDetails && !titleRenaming ? (
<WorktreeCardIdentityHover
card={card}
branchName={hoverBranchName}
workspaceTitle={hoverWorkspaceTitle}
>
{identityContent}
</WorktreeCardIdentityHover>
) : (
identityContent
)
return (
<div
className={cn('flex w-full min-w-0 gap-0.5', titleOnlyCard ? 'items-center' : 'items-start')}
style={
parentContentMarginLeft < 0 ? { marginLeft: `${parentContentMarginLeft}px` } : undefined
}
data-worktree-card-parent-content=""
>
{showCombinedStatusSlot ? (
<WorktreeCardStatusLane card={card} showUnreadAction={showUnreadQuickAction} />
) : null}
<div
className={cn(
'flex min-w-0 flex-1 flex-col gap-1.5',
// Why: inline agent rows intentionally outdent into the card gutter; inner elements handle truncation.
showInlineAgentList || getWorkspaceAttachments(worktree).length > 1
? 'overflow-visible'
: 'overflow-hidden'
)}
>
{identityContentWithHover}
<WorktreeCardSecondaryRows card={card} presentation={presentation} />
</div>
</div>
)
}
@@ -5,6 +5,7 @@ import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-h
import CacheTimer from '../activity/CacheTimer'
import { TruncatedSidebarLabel } from '../../truncated-sidebar-label'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
import { useIsCloudHostRow } from '@/lib/orca-cloud/cloud-host-rows'
import {
WorktreeCardDeleteQuickAction,
WorktreeCardPrimaryBadge,
@@ -100,6 +101,20 @@ function sidebarLabel(text: string, tooltip: unknown): React.ReactNode {
)
}
/**
* An Orca Cloud workspace's own checkout (owned or PAIRED) IS the workspace, not a repo's primary
* checkout: no "primary" badge or star on it (CLOUD-PRIMARY-DELETE, PAIRED-BADGE).
*/
function NotOnCloudRow({
worktree,
children
}: {
worktree: WorktreeCardController['worktree']
children: React.ReactNode
}): React.JSX.Element | null {
return useIsCloudHostRow(worktree) ? null : <>{children}</>
}
export const CARD_IDENTITY_PARTS = {
Title: part(['dimWhenRead', 'tooltip'], (props, _children, card) => title(props, card)),
RepoChip: part([], (_props, _children, { repo }) =>
@@ -134,9 +149,15 @@ export const CARD_IDENTITY_PARTS = {
/>
)),
PrimaryBadge: part(['hasHoverDetails'], (props, _children, card) => (
<WorktreeCardPrimaryBadge card={card} hasHoverDetails={props.hasHoverDetails === true} />
<NotOnCloudRow worktree={card.worktree}>
<WorktreeCardPrimaryBadge card={card} hasHoverDetails={props.hasHoverDetails === true} />
</NotOnCloudRow>
)),
PrimaryStar: part([], (_props, _children, card) => (
<NotOnCloudRow worktree={card.worktree}>
<WorktreeCardPrimaryStar />
</NotOnCloudRow>
)),
PrimaryStar: part([], () => <WorktreeCardPrimaryStar />),
SparseBadge: part(['hasHoverDetails'], (props, _children, card) => (
<WorktreeCardSparseBadge
card={card}
@@ -0,0 +1,53 @@
// PAIRED-BADGE: the "primary" badge and star the LIVE card template draws (default-workspace-card.json
// through the element registry) are gated on the cloud row check, so an Orca Cloud workspace's own
// checkout (owned, or paired from birth) never reads as a repo's primary checkout. #560 put this gate
// in a header nothing rendered; this test goes through the registry the sidebar actually uses.
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
vi.mock('@/lib/orca-cloud/cloud-host-rows', () => ({
// A paired-from-birth row is on its "(cloud)" server; the hook itself is covered in its own test.
useIsCloudHostRow: (row: { hostId?: string | null }) => row.hostId === 'runtime:env-gh'
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import { WORKSPACE_CARD_ELEMENTS } from './card-element-registry'
import { compileCardTemplate } from './card-template-compiler'
import { DEFAULT_WORKSPACE_CARD_TEMPLATE } from './default-workspace-card'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
function renderElement(el: 'PrimaryBadge' | 'PrimaryStar', hostId: string): string {
const compiled = compileCardTemplate(
{ ...DEFAULT_WORKSPACE_CARD_TEMPLATE, defs: undefined, root: { el } },
WORKSPACE_CARD_ELEMENTS
)
const worktree = {
id: 'push-test::/workspace/orca-sandbox-push-test',
hostId,
isMainWorktree: true
}
const fields = { worktree, newCardStyle: false, detailsHoverControl: null }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these parts read only `worktree`, `newCardStyle` and `detailsHoverControl`.
const card = fields as unknown as WorktreeCardController
return renderToStaticMarkup(
<TooltipProvider>{compiled.render({}, card).content}</TooltipProvider>
)
}
describe('the live card template on an Orca Cloud workspace row', () => {
it('the template the sidebar renders still draws both parts', () => {
const json = JSON.stringify(DEFAULT_WORKSPACE_CARD_TEMPLATE)
expect(json).toContain('"el":"PrimaryBadge"')
expect(json).toContain('"el":"PrimaryStar"')
})
it('a paired-from-birth cloud row gets no "primary" badge and no star', () => {
expect(renderElement('PrimaryBadge', 'runtime:env-gh')).not.toContain('primary')
expect(renderElement('PrimaryStar', 'runtime:env-gh')).toBe('')
})
it('a local primary checkout keeps both', () => {
expect(renderElement('PrimaryBadge', 'local')).toContain('primary')
expect(renderElement('PrimaryStar', 'local')).toContain('Primary worktree')
})
})
+8
View File
@@ -242,10 +242,18 @@
"blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.",
"blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first."
},
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
"invitedCount_one": "{{count}} invited",
"invitedCount_other": "{{count}} invited",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"openSeatsClause_one": "You have {{count}} open seat",
"openSeatsClause_other": "You have {{count}} open seats",
"seatCount_one": "{{count}} seat",
"seatCount_other": "{{count}} seats",
"sendInvites_one": "Send {{count}} invite",
+17 -22
View File
@@ -21095,7 +21095,6 @@
"notFound": "That member or invite no longer exists.",
"failed": "Could not update the team. Try again.",
"actionsFor": "Actions for {{value0}}",
"billing": "Billing",
"celebrateBody2": "{{value0}} plan · {{value1}} active. Agents keep running in the cloud when you step away.",
"celebrateTitle2": "Welcome to Orca Cloud",
"createBody": "You’re on a personal account. A team shares one Orca Cloud plan: invite people, manage seats and billing in one place. You’ll be its owner.",
@@ -21109,13 +21108,6 @@
"description": "Members, invites, roles, seats and your Orca Cloud plan.",
"dismiss": "Dismiss",
"enterEmail": "Enter a teammate’s email",
"inviteAddsMany3": "{{value0}} of these add a seat each when accepted: about {{value1}} each now (prorated), then {{value2}} each.",
"inviteAddsOne2": "When {{value0}} accepts, they add a seat: about {{value1}} prorated, then {{value2}}.",
"inviteAddsUnpriced": "Each accepted invite above your paid seats adds a seat, charged now and prorated.",
"inviteCoveredMany": "Covered: these use seats you already paid for this period.",
"inviteCoveredOne": "Covered: uses a seat you already paid for this period.",
"inviteCoveredPart": "The first {{value0}} use seats already paid.",
"inviteHint": "Paste a list: commas, spaces and new lines all work. Invites don’t take a seat until accepted.",
"inviteLabel": "Invite teammates",
"inviteNoPlan": "Invites are free. Seats are billed once your team picks a plan.",
"invitePlaceholder2": "alex@company.com, sam@company.com",
@@ -21125,12 +21117,9 @@
"inviteTeammates": "Invite teammates",
"lastOwner": "Make someone else an owner before you leave.",
"leave": "Leave team…",
"leaveConfirmBody": "You lose access to the team’s cloud workspaces and secrets right away. An owner or admin can invite you back.",
"leaveConfirmTitle": "Leave {{value0}}?",
"leaveCta": "Leave team",
"leaveLastOwner": "You’re the only owner. Make someone else an owner first.",
"leaveNeedsOwner": "Make someone else an owner first",
"leaveTitle": "Leave {{value0}}",
"leftToast": "You left {{value0}}",
"loadFailed2": "Couldn’t load your team. Check your connection.",
"loadFailedTitle": "Couldn’t load team",
@@ -21157,7 +21146,6 @@
"roleAdminHint": "Manages members and billing",
"roleMemberHint": "Uses Orca Cloud with the team",
"roleOwnerHint": "Everything, including billing and other owners",
"seatRules": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal, and re-adding someone this period is free.",
"seatsDropOn": "Drops to {{value0}} on {{value1}}",
"seatsInUse": "{{value0}} in use",
"seatsPaid": "Seats paid",
@@ -21172,12 +21160,7 @@
"thisTeam": "this team",
"undo": "Undo",
"unsupported2": "This isn’t available yet. Ask an owner or admin to do it for you.",
"usage": "Usage & plan",
"youAreAdmin": "You’re an admin",
"youAreMember": "You’re a member",
"youAreOwner": "You’re an owner",
"yourTeams": "Or open a team you’re in",
"ssoBody": "SAML SSO and SCIM user provisioning are part of {{value0}}.",
"ssoTitle": "Single sign-on and provisioning",
"createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.",
"invitedCount_one": "{{count}} invited",
@@ -21199,11 +21182,8 @@
"nowAdmin": "{{value0}} is now an admin",
"nowMember": "{{value0}} is now a member",
"nowOwner": "{{value0}} is now an owner",
"perSeatPerMonth": "{{value0}} per seat per month",
"createInvalid2": "Use letters, numbers, spaces or underscores (up to 64).",
"nameTaken": "That team name is taken. Try another.",
"totalPerMonth": "{{total}} per month",
"totalPerYear": "{{total}} per year",
"openProfile": "Open {{value0}}’s profile",
"viewProfile": "View profile",
"seatsOnPlanBilling": "Seats and invoices are in",
@@ -21225,7 +21205,23 @@
"failed": "Couldn’t delete the team. Try again.",
"deletedToast2": "{{value0}} was deleted"
},
"leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team."
"leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team.",
"planBillingLink": "Plan & billing",
"perSeat": "Per seat",
"perSeatMonthValue": "{{value0}}/mo",
"seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.",
"openSeatsNone": "You have no open seats",
"openSeatsClause_one": "You have {{count}} open seat",
"openSeatsClause_other": "You have {{count}} open seats",
"inviteSeatUnknown": "Each invite uses a seat once accepted.",
"inviteSeatOpen": "Each invite uses a seat once accepted. {{openSeats}}.",
"inviteSeatCovered": "Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.",
"inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.",
"inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.",
"inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.",
"inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.",
"inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added."
},
"manageBilling": "Manage billing",
"comparePlans": "Compare plans",
@@ -21262,7 +21258,6 @@
"seatsFollow2": "Seats follow your team: new members add a seat now; removed seats drop off at renewal.",
"loadFailedTitle": "Plans didn’t load",
"savePercent": "Save {{value0}}%",
"seatsPeriod": "{{value0}} · {{value1}}",
"was": "Was",
"featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team",
"featureSpec": "Every sandbox gets {{value0}} vCPU · {{value1}} GB",
@@ -15,6 +15,13 @@ import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-co
import { tuiAgentDisplayName } from '../../../../shared/agent-launch/catalog/tui-agent-display-names'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { isOrcaCloudEnvironmentForLaunch } from './orca-cloud-environment-id'
import { readHeldCloudLaunches } from './cloud-held-launch-memory'
import {
beginWebRuntimeInitialTerminalBootstrap,
endWebRuntimeInitialTerminalBootstrap,
isWebRuntimeInitialTerminalBootstrapInFlight,
markWebRuntimeInitialTerminalBootstrapAwaitingMirror
} from '@/runtime/web-session-tabs-sync/initial-terminal/web-runtime-initial-terminal-bootstrap'
export type FirstCloudAgentRequest = {
fullName: string
@@ -39,17 +46,29 @@ export async function launchFirstCloudAgent(request: FirstCloudAgentRequest): Pr
const worktreeId =
request.worktreeId ?? (request.hostId ? await waitForFirstWorktree(request.hostId) : null)
const host = parseExecutionHostId(request.hostId)
const environmentId = host?.kind === 'runtime' ? host.environmentId : null
// PAIRED-HELD-ACTIVE: the agent's tab (or its held placeholder) is this workspace's first terminal.
// Without the claim, the mirror's first empty frame seeds a plain shell too, activated, which hides
// the held placeholder and its "Set up once" strip behind a bare prompt (n39).
const claimed =
worktreeId && environmentId
? beginWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
: false
const confirmed =
!worktreeId ||
host?.kind !== 'runtime' ||
(await waitForOrcaCloudEnvironment(host.environmentId, deadline))
!worktreeId || !environmentId || (await waitForOrcaCloudEnvironment(environmentId, deadline))
if (!worktreeId || !confirmed) {
if (claimed && worktreeId && environmentId) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
}
agentNotStartedNotice(request.agent, request.fullName, request.hostId)
return
}
const agent = isTuiAgent(request.agent)
? request.agent
: await resolveDefaultAgentForWorktree(useAppStore.getState, worktreeId).catch(() => null)
if (environmentId) {
void settleFirstTerminal({ environmentId, worktreeId, claimed, launched: Boolean(agent) })
}
if (!agent) {
return
}
@@ -63,6 +82,108 @@ export async function launchFirstCloudAgent(request: FirstCloudAgentRequest): Pr
})
}
/** How long the first agent's tab may take to reach this workspace's mirror. */
const FIRST_TAB_WAIT_MS = 30_000
/**
* The claimed bootstrap latch is released once the workspace has a tab (the seeder then sees it), or
* handed back to the seeder after the wait so a launch that never arrived still gets its shell. If the
* seeder had already claimed it, its shell may land on top: once it settles, the held placeholder is
* brought to the front, once, while the person is still on this workspace.
*/
async function settleFirstTerminal(args: {
environmentId: string
worktreeId: string
claimed: boolean
launched: boolean
}): Promise<void> {
const { environmentId, worktreeId } = args
// Tabs the person could already be on; the seeder's late shell is never one of them.
const tabsAtLaunch = new Set(
(useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
)
if (args.claimed && !args.launched) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
return
}
const heldTab = (): string | null => {
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
const record = readHeldCloudLaunches(environmentId).find(
(each) => each.worktreeId === worktreeId && tabs.some((tab) => tab.id === each.tabId)
)
return record?.tabId ?? null
}
const hasTab = (): boolean => (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0
const ready = await waitForStore(
() =>
args.claimed
? hasTab()
: !isWebRuntimeInitialTerminalBootstrapInFlight(environmentId, worktreeId) &&
heldTab() !== null,
FIRST_TAB_WAIT_MS
)
if (args.claimed) {
if (ready) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
} else {
markWebRuntimeInitialTerminalBootstrapAwaitingMirror(environmentId, worktreeId)
}
return
}
const tab = ready ? heldTab() : null
if (tab && seederShellInFront(worktreeId, tab, tabsAtLaunch)) {
useAppStore.getState().setActiveTab(tab)
}
}
/**
* Never steals focus: only the seeder's late shell (the newest tab, made after the launch began) is
* replaced in front. Another workspace, or a tab the person was already on, keeps focus.
*/
function seederShellInFront(
worktreeId: string,
heldTabId: string,
tabsAtLaunch: ReadonlySet<string>
): boolean {
const state = useAppStore.getState()
const active = state.activeTabId
const tabs = state.tabsByWorktree[worktreeId] ?? []
return (
state.activeWorktreeId === worktreeId &&
active !== null &&
active !== heldTabId &&
!tabsAtLaunch.has(active) &&
tabs.at(-1)?.id === active
)
}
function waitForStore(done: () => boolean, waitMs: number): Promise<boolean> {
if (done()) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
let recheck: ReturnType<typeof setTimeout> | undefined
const finish = (value: boolean): void => {
unsubscribe()
clearTimeout(recheck)
clearTimeout(timer)
resolve(value)
}
const check = (): void => {
if (done()) {
finish(true)
return
}
// The seeder's latch is not store state: it ends just after the mirror change that came
// with its shell, so look once more shortly after the last change.
clearTimeout(recheck)
recheck = setTimeout(() => done() && finish(true), 300)
}
const unsubscribe = useAppStore.subscribe(check)
const timer = setTimeout(() => finish(done()), waitMs)
})
}
async function waitForOrcaCloudEnvironment(
environmentId: string,
deadline: number
@@ -0,0 +1,204 @@
// @vitest-environment happy-dom
// PAIRED-HELD-ACTIVE (n39): after a GitHub-only Orca Cloud create (GH-CREATE-SHEET hand-off) whose
// agent isn't set up, the held placeholder is the workspace's one tab and it is in front, so its
// "Set up once" strip is what the person sees. The mirror's initial-terminal seeder must not add a
// plain shell over it; if the seeder got there first, the held tab is brought forward once, but
// never over a tab the person chose or another workspace.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
import { setOrcaCloudAgentAccountsStateForTest } from './orca-cloud-agent-accounts-store'
import {
cloudTabHoldKey,
readCloudAgentHold,
releaseCloudAgentHold
} from './cloud-agent-launch-hold'
import {
beginWebRuntimeInitialTerminalBootstrap,
endWebRuntimeInitialTerminalBootstrap,
isWebRuntimeInitialTerminalBootstrapInFlight,
resetWebRuntimeInitialTerminalBootstrapForTests
} from '@/runtime/web-session-tabs-sync/initial-terminal/web-runtime-initial-terminal-bootstrap'
const host = vi.hoisted(() => {
const value: {
creates: { launchAgent?: string; environmentId: string | null; activate?: boolean }[]
} = { creates: [] }
return value
})
/** The mirror publishing a host tab (activated unless the create said not to). */
function mirrorTab(tabId: string, activate: boolean): void {
const state = useAppStore.getState()
const tabs = [...(state.tabsByWorktree['wt-1'] ?? []), { id: tabId, worktreeId: 'wt-1' }]
useAppStore.setState({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only tab ids
tabsByWorktree: { ...state.tabsByWorktree, 'wt-1': tabs } as never,
...(activate && state.activeWorktreeId === 'wt-1' ? { activeTabId: tabId } : {})
})
}
vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({
openCloudWorkspace: async () => ({ ok: true, worktreeId: 'wt-1', hostId: 'runtime:env-paired' }),
firstWorktreeOnHost: () => 'wt-1'
}))
vi.mock('@/runtime/web-session-tabs-sync/actions/web-runtime-session-tab-lifecycle', () => ({
closeWebRuntimeSessionTab: async () => 'applied'
}))
vi.mock('@/runtime/hosted-terminals/placement/web-session-terminal-placement', () => ({
webTerminalPlacementParentTabId: (id: string) => id
}))
vi.mock('@/components/agent-tab-launch-context', () => ({
captureAgentTabLaunchContext: () => ({})
}))
// The new-tab launch on a paired web host is one host terminal create through the account hold.
vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', async () => {
const { createWithCloudAgentHold } = await import('./cloud-web-host-launch-hold')
return {
launchAgentInNewTabWithOutcome: (args: { agent: TuiAgent; worktreeId: string }) => {
void createWithCloudAgentHold(
{ worktreeId: args.worktreeId, environmentId: 'env-paired', launchAgent: args.agent },
async (create) => {
host.creates.push(create)
const hostTabId = `h${host.creates.length}`
mirrorTab(toWebTerminalSurfaceTabId(hostTabId), create.activate !== false)
return { outcome: { status: 'created' }, hostTabId }
}
)
}
}
})
const { startCloudWorkspaceFromGitHub } = await import('./start-cloud-workspace-from-github')
const { toWebTerminalSurfaceTabId } =
await import('../../../../shared/workspace-layout/terminal-surface-id')
beforeEach(() => {
host.creates = []
resetWebRuntimeInitialTerminalBootstrapForTests()
localStorage.clear()
setOrcaCloudAgentAccountsStateForTest({
load: {
kind: 'ready',
status: {
credentials: {
claude: { state: 'none' },
codex: { state: 'none' },
providerEnabled: { claude: true, codex: true }
},
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
}
},
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } },
keyKinds: { claude: null, codex: null },
queue: { agents: [], index: 0 }
})
// The fresh pairing is in the catalog without its orcaCloudPaired flag yet; main knows it.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the checks read only id, name and source
const runtimeEnvironments = [
{ id: 'env-paired', name: 'web (cloud)', source: 'manual' }
] as unknown as ReturnType<typeof useAppStore.getState>['runtimeEnvironments']
useAppStore.setState({
pendingWorktreeCreations: {},
runtimeEnvironments,
activeWorktreeId: 'wt-1',
activeTabId: null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test reads only tab ids
tabsByWorktree: {} as never
})
Object.assign(window, {
api: {
...window.api,
orcaCloudWorkspace: {
createFromGitHub: async () => ({ ok: true, value: { sandboxId: 'sbx_1' } })
},
cloudWorkspaces: { get: async (id: string) => ({ ok: true, value: { id } }) },
ephemeralVm: { onProvisionEvent: () => () => undefined },
orcaCloudAgents: {
isCloudEnvironment: async ({ runtimeEnvironmentId }: { runtimeEnvironmentId: string }) =>
runtimeEnvironmentId === 'env-paired'
}
}
})
})
const create = () =>
startCloudWorkspaceFromGitHub({
repoUrl: 'https://github.com/acme/web',
fullName: 'acme/web',
prompt: '',
agent: 'claude'
})
const HELD = () => toWebTerminalSurfaceTabId('h1')
const tabIds = () => (useAppStore.getState().tabsByWorktree['wt-1'] ?? []).map((tab) => tab.id)
/** The seeder's plain shell landing late, activated (what hid the strip on n39). */
function seederShellLands(): void {
mirrorTab('web-terminal-seeded-shell', true)
endWebRuntimeInitialTerminalBootstrap('env-paired', 'wt-1')
}
describe('PAIRED-HELD-ACTIVE: the held placeholder is in front after a GitHub-only create', () => {
it('exactly one tab, the held placeholder, active; the seeder is held off meanwhile', async () => {
await expect(create()).resolves.toBe(true)
// While the agent's launch is on its way, the seeder can't claim the first terminal.
expect(isWebRuntimeInitialTerminalBootstrapInFlight('env-paired', 'wt-1')).toBe(true)
await vi.waitFor(() => expect(readCloudAgentHold(cloudTabHoldKey(HELD()))).not.toBeNull())
await vi.waitFor(() =>
expect(isWebRuntimeInitialTerminalBootstrapInFlight('env-paired', 'wt-1')).toBe(false)
)
expect(tabIds()).toEqual([HELD()])
const active = useAppStore.getState().activeTabId
expect(active).toBe(HELD())
// The strip resolves for the tab in front.
expect(readCloudAgentHold(cloudTabHoldKey(active!))).toMatchObject({ reason: 'notSetUp' })
releaseCloudAgentHold(cloudTabHoldKey(HELD()), 'cancel')
})
it('the seeder got there first: once its shell lands in front, the held tab is brought forward', async () => {
beginWebRuntimeInitialTerminalBootstrap('env-paired', 'wt-1')
await create()
await vi.waitFor(() => expect(tabIds()).toEqual([HELD()]))
seederShellLands()
await vi.waitFor(() => expect(useAppStore.getState().activeTabId).toBe(HELD()))
releaseCloudAgentHold(cloudTabHoldKey(HELD()), 'cancel')
})
it('never steals focus from a tab the person is on in that workspace', async () => {
mirrorTab('web-terminal-mine', true)
beginWebRuntimeInitialTerminalBootstrap('env-paired', 'wt-1')
await create()
await vi.waitFor(() => expect(tabIds()).toContain(HELD()))
// Fake time from here: the settle's recheck after the seeder's shell runs on a timer.
vi.useFakeTimers()
try {
mirrorTab('web-terminal-seeded-shell', false)
useAppStore.setState({ activeTabId: 'web-terminal-mine' })
endWebRuntimeInitialTerminalBootstrap('env-paired', 'wt-1')
await vi.advanceTimersByTimeAsync(600)
} finally {
vi.useRealTimers()
}
expect(useAppStore.getState().activeTabId).toBe('web-terminal-mine')
releaseCloudAgentHold(cloudTabHoldKey(HELD()), 'cancel')
})
it('never steals focus from another workspace', async () => {
beginWebRuntimeInitialTerminalBootstrap('env-paired', 'wt-1')
await create()
await vi.waitFor(() => expect(tabIds()).toEqual([HELD()]))
useAppStore.setState({ activeWorktreeId: 'wt-2', activeTabId: 'web-terminal-elsewhere' })
// Fake time from here: the settle's recheck after the seeder's shell runs on a timer.
vi.useFakeTimers()
try {
seederShellLands()
await vi.advanceTimersByTimeAsync(600)
} finally {
vi.useRealTimers()
}
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: 'wt-2',
activeTabId: 'web-terminal-elsewhere'
})
releaseCloudAgentHold(cloudTabHoldKey(HELD()), 'cancel')
})
})