mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
+93
@@ -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')}
|
||||
|
||||
+51
@@ -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')
|
||||
|
||||
-103
@@ -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>
|
||||
)
|
||||
}
|
||||
-80
@@ -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>
|
||||
)
|
||||
}
|
||||
+23
-2
@@ -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}
|
||||
|
||||
+53
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user