diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index 60c6f010c30..c7a4a4322d4 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -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). */ diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 64590a5fa2f..83dac74951c 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -1247,13 +1247,14 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise { 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) + }) }) diff --git a/cloud/docs/accounts-billing-design.md b/cloud/docs/accounts-billing-design.md index 45b5d041d43..f89bb7fe4d4 100644 --- a/cloud/docs/accounts-billing-design.md +++ b/cloud/docs/accounts-billing-design.md @@ -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`.) diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index d6960e64ae5..e580f5ec8f8 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -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", diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index 3bf36cab258..e07ef549b3e 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -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({
{entitlement.planLabel} - - {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') - })} -
{warning ?

{warning}

: null} + {/* Seat facts moved here from the Team dialog, which now only links to Plan & billing. */} + ) } @@ -186,7 +181,7 @@ export function PlansView(props: PlansViewProps): React.JSX.Element { busy={props.busy} onUpdatePayment={props.onManage} /> - + ) : isOrcaCloudTrial(entitlement) ? ( // The trial's strip: the plan cards right below are the way out of it (spec T1). diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 8b17fb6737d..9db2b6353ce 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -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 } diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx index 0831dc006bd..566ea08c500 100644 --- a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx @@ -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 } -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

- {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.' diff --git a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx index 57411710e07..ac0b97469db 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx @@ -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( + + ) + 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() +}) diff --git a/src/renderer/src/components/billing/team/TeamMemberList.tsx b/src/renderer/src/components/billing/team/TeamMemberList.tsx index 083d064cc2d..ed65b8ed652 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.tsx @@ -40,6 +40,8 @@ export type TeamMemberListProps = { rowStates: Readonly> /** 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…')} ) + if (!props.canLeave && props.leaveBlockedReason) { + // A disabled item alone doesn't say why; the last owner needs the way out spelled out. + items.push( +

+ {props.leaveBlockedReason} +

+ ) + } } if (items.length === 0) { // Keeps the role column aligned with rows that have a menu. @@ -219,7 +233,7 @@ function RowMenu({ - + {items} diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index 4bf0fcf7a5b..7650919798d 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -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 { + // The dialog portals to , outside the fixture frame. + const input = document.querySelector('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: () => , - interact: async () => { - // The dialog portals to , outside the fixture frame. - const input = document.querySelector('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: () => , + 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: () => , + 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: () => , + interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') +}) + +/** Short roster so the Enterprise row sits inside the frame. */ +export const ssoRow = defineFixture({ width, render: () => ( ". +/** The only owner opens the ⋯ on their own row: Leave is off and says what to do instead. */ +export const lastOwnerLeaveBlocked = defineFixture({ + width, + render: () => , + interact: async () => { + const trigger = document.querySelector( + '[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: () => ( +
+ + + +
+ ) +}) + +// 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 ( diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 0689adc66fa..c32779daf8f 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -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 ( -
-
- - -
-
- {[0, 1, 2].map((index) => ( -
- - - -
- ))} -
- - -
- ) -} - function SkeletonRows(): React.JSX.Element { return (
('[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')}

+ {!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. + + ) : null} {props.arrival?.kind === 'checkout-success' && entitlement ? ( @@ -211,15 +191,16 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {

) : entitlement ? ( - - ) : props.billing === null ? ( - + <> + + {isActivePlan(entitlement) ? null : ( + + )} + ) : 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} - {(roster?.canManageMembers && enterprise) || selfRow ? ( + {(roster?.canManageMembers && enterprise) || props.dangerZone ? (
{roster?.canManageMembers && enterprise ? (
-
-

- {t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')} -

-

- {t( - 'auto.components.billing.team.ssoBody', - 'SAML SSO and SCIM user provisioning are part of {{value0}}.', - { value0: enterprise.label } - )} -

-
+

+ {t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')} +

) : null} - {selfRow ? ( -
-
-

- {t('auto.components.billing.team.leaveTitle', 'Leave {{value0}}', { - value0: props.org.name - })} -

-

- {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.' - )} -

-
- - - -
- ) : null} {props.dangerZone}
) : null} diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 131d14a71b4..35a6fd49a3d 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -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 ( -
-
-

{entitlement.planLabel}

- - - {entitlement.canManageBilling ? ( - <> - - - {t('auto.components.billing.manageBilling', 'Manage billing')} - - - - ) : null} -
+
{invoice ? (

{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.' )}

@@ -269,34 +241,3 @@ export function TeamUpgradeCard({ ) } - -export function TeamBillingBlock({ - entitlement, - members, - busy, - onManageBilling, - onSeePlans -}: { - entitlement: OrcaBillingEntitlement - members: number - busy: boolean - onManageBilling: () => void - onSeePlans: () => void -}): React.JSX.Element { - return ( -
- - {isActivePlan(entitlement) ? ( - - ) : ( - - )} -
- ) -} diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts new file mode 100644 index 00000000000..e642e810f4c --- /dev/null +++ b/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts @@ -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 { + 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.') + }) +}) diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.ts new file mode 100644 index 00000000000..c51d6e50e79 --- /dev/null +++ b/src/renderer/src/components/billing/team/team-invite-impact-copy.ts @@ -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.' + } + ) + } + } +} diff --git a/src/renderer/src/components/billing/team/team-leave.test.ts b/src/renderer/src/components/billing/team/team-leave.test.ts new file mode 100644 index 00000000000..2b27be53485 --- /dev/null +++ b/src/renderer/src/components/billing/team/team-leave.test.ts @@ -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.' + ) + }) +}) diff --git a/src/renderer/src/components/billing/team/team-leave.ts b/src/renderer/src/components/billing/team/team-leave.ts new file mode 100644 index 00000000000..0f956f93419 --- /dev/null +++ b/src/renderer/src/components/billing/team/team-leave.ts @@ -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.' + ) +} diff --git a/src/renderer/src/components/billing/team/team-seat-math.test.ts b/src/renderer/src/components/billing/team/team-seat-math.test.ts index 314b0f5ed59..94e18f86817 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.test.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.test.ts @@ -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 }) }) }) diff --git a/src/renderer/src/components/billing/team/team-seat-math.ts b/src/renderer/src/components/billing/team/team-seat-math.ts index bf6184126d2..928ac36cc41 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.ts @@ -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), diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.fixture.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.fixture.tsx new file mode 100644 index 00000000000..dd775e6ebe1 --- /dev/null +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.fixture.tsx @@ -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 => new Promise((resolve) => setTimeout(resolve, 80)) + +const model = orcaCloudAgentMenuModel({ defaultTuiAgent: 'claude', disabledTuiAgents: [] }) +const STATUS: ReadonlyMap = new Map([ + ['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 ( +
+
+

Project: ~ (folder) · Run on: Orca Cloud

+ {children} +
+
+ ) +} + +const open = async (frame: HTMLElement): Promise => { + // Radix menus open on pointerdown, not click. + frame + .querySelector('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: () => ( + + + + ), + interact: open +}) + +/** Before (n39): the same menu at its compact width, for comparison. */ +export const homeFolderOrcaCloudBefore = defineFixture({ + width: 500, + render: () => ( + + + + ), + interact: open +}) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx index 3c7d5549a0f..b00e1aae591 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx @@ -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')} diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.width.test.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.width.test.tsx new file mode 100644 index 00000000000..9cc11c9f996 --- /dev/null +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.width.test.tsx @@ -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( + + + + ) + const trigger = view.container.querySelector('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') + }) +}) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx index d8703ad0ab8..590ef8896b3 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx @@ -109,7 +109,7 @@ export const capabilityOff = defineFixture({ 'claude', 'Claude', false, - { contactUrl: 'https://onorca.dev/contact-sales' } + { contactUrl: 'https://orca.dev/enterprise' } ) }) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.test.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.test.ts index 0893ae147f5..e0b65103a7f 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.test.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.test.ts @@ -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') diff --git a/src/renderer/src/components/sidebar/workspace-card/metadata/worktree-card-meta-row.tsx b/src/renderer/src/components/sidebar/workspace-card/metadata/worktree-card-meta-row.tsx deleted file mode 100644 index ae84ae814ec..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/metadata/worktree-card-meta-row.tsx +++ /dev/null @@ -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 ( -
-
- {showRepoBadgeInMetaRow && repo && } - - {showHostContextBadge && - (wslDistro ? ( - - ) : ( - - ))} - - - - {showIdentityInNewCard ? ( - - ) : isFolder && !newCardStyle ? ( - - ) : showBranch ? ( - - ) : showDetachedHeadInMetaRow && detachedHeadDisplay ? ( - - ) : null} - - {showConflictOperationBadge && conflictOperation && ( - - )} - - {cacheStartedAt != null && } -
- - {showMetaRowDetails && ( -
{detailsAndPorts}
- )} -
- ) -} diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx deleted file mode 100644 index bbc1c15720b..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx +++ /dev/null @@ -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 ( -
-
- {showPinnedRepoIcon && } - - - ) : undefined - } - runtimeMark={ - !repo?.connectionId && parsedRepoHost?.kind === 'runtime' ? ( - - ) : undefined - } - /> - - {showInlineRepoBadge && } - - setRenamingWorktreeId(null)} - /> - - {/* N-03: the agent waits on its pane's strip (Orca Cloud): this mark is the signal away. */} - {!titleRenaming ? : null} - - {typeof worktree.firstAgentMessageRenameError === 'string' && - worktree.firstAgentMessageRenameError.length > 0 && - !titleRenaming ? ( - - ) : null} - {!compactCards && worktree.isMainWorktree && !isFolder && !cloudRow && ( - - )} - - {worktree.isSparse && ( - - )} - - {showTitleRowIndicators && titleRowIndicators} -
- - {showHeaderActions && ( -
- {showTitleRowPrimary && !cloudRow && } - - {showDeleteQuickAction && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-parent-content.tsx deleted file mode 100644 index a5b7ef8d476..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-parent-content.tsx +++ /dev/null @@ -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 = ( -
- - {presentation.hasMetaRow && } -
- ) - // Why: status glyphs and agent rows own their tooltips; only identity content should open the larger details card. - const identityContentWithHover = - hasHoverDetails && !titleRenaming ? ( - - {identityContent} - - ) : ( - identityContent - ) - - return ( -
- {showCombinedStatusSlot ? ( - - ) : null} - -
1 - ? 'overflow-visible' - : 'overflow-hidden' - )} - > - {identityContentWithHover} - -
-
- ) -} diff --git a/src/renderer/src/components/sidebar/workspace-card/template/card-identity-parts.tsx b/src/renderer/src/components/sidebar/workspace-card/template/card-identity-parts.tsx index fd7f14ba442..230bfd25ed2 100644 --- a/src/renderer/src/components/sidebar/workspace-card/template/card-identity-parts.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/template/card-identity-parts.tsx @@ -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) => ( - + + + + )), + PrimaryStar: part([], (_props, _children, card) => ( + + + )), - PrimaryStar: part([], () => ), SparseBadge: part(['hasHoverDetails'], (props, _children, card) => ( ({ + // 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( + {compiled.render({}, card).content} + ) +} + +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') + }) +}) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 6f69d94a817..a987535364d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2defe86eea8..b396585c676 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/lib/orca-cloud/github-cloud-first-agent.ts b/src/renderer/src/lib/orca-cloud/github-cloud-first-agent.ts index a5a31e7d9a6..4ed25f8549d 100644 --- a/src/renderer/src/lib/orca-cloud/github-cloud-first-agent.ts +++ b/src/renderer/src/lib/orca-cloud/github-cloud-first-agent.ts @@ -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 { + 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 +): 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 { + if (done()) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let recheck: ReturnType | 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 diff --git a/src/renderer/src/lib/orca-cloud/github-cloud-first-tab.test.ts b/src/renderer/src/lib/orca-cloud/github-cloud-first-tab.test.ts new file mode 100644 index 00000000000..dcaf8b630d2 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/github-cloud-first-tab.test.ts @@ -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['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') + }) +})