Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/daemon/src/integrations/vela-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ export interface AmrAccountFailureSignal {
stderrTail?: unknown;
}

// `source=open_design` tags the wallet landing page_view so vela analytics can
// `source=open_design` tags the console landing page_view so vela analytics can
// attribute the recharge visit to Open Design.
//
// The console dashboard, not a wallet page: balance and manual top-up were
// rehomed onto it (vela #1055) and the wallet route left the product's
// information architecture, so this link must not send a user there.
export const DEFAULT_AMR_RECHARGE_URL =
'https://open-design.ai/amr/wallet?source=open_design';
'https://open-design.ai/amr/dashboard?source=open_design';

const AMR_AUTH_REQUIRED_MESSAGE =
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.';

const AMR_INSUFFICIENT_BALANCE_MESSAGE =
`AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`;
`AMR Cloud reported insufficient balance for this model. Top up your AMR balance at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`;

const AMR_TIER_UPGRADE_REQUIRED_MESSAGE =
'Your current AMR plan does not include this model or request type. Upgrade your AMR plan, or switch to an available model and retry.';
Expand Down
12 changes: 12 additions & 0 deletions apps/daemon/tests/integrations/vela-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import {
} from '../../src/integrations/vela-errors.js';

describe('AMR account failure classification', () => {
// The recharge link the daemon hands to the client is a real destination a
// user clicks. Product retired the console's wallet page — balance and manual
// top-up report on its dashboard now (vela #1055) — so pin the literal here:
// every other assertion in this file references the constant symbolically and
// would keep passing while pointing users at a surface the product no longer
// navigates to.
it('points the recharge link at the console dashboard, not a wallet page', () => {
expect(DEFAULT_AMR_RECHARGE_URL).toBe(
'https://open-design.ai/amr/dashboard?source=open_design',
);
});

it('classifies insufficient_balance JSON-RPC failures as rechargeable AMR balance errors', () => {
const failure = classifyAmrAccountFailure(
'JSON-RPC error -32000: {"code":"insufficient_balance","message":"insufficient balance"}',
Expand Down
21 changes: 11 additions & 10 deletions apps/web/src/components/AmrBalanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,17 @@ export function AmrBalanceDialog({
// doesn't poll forever; guarded against double-fires.
const [watchingWallet, setWatchingWallet] = useState(false);
// Where 「升级套餐」 goes. `workspaceUpgradeUrl` is the one decision point for
// every upgrade affordance: personal workspace → B's wallet pricing modal
// (`view=plans`); team → `billing=checkout` vs `billing=plan` by whether the
// team ever completed a first checkout. Getting either branch wrong opens
// the wrong dialog — or an error-state one: routing a personal workspace
// onto the team `billing=checkout` deep link opened the Upgrade-to-Team
// dialog with "Team plan unavailable" / a 3-seat minimum (recvpYEiH019cD,
// failed acceptance round). The profile fallback keeps the CTA alive after
// a signed-out/no-context read (but not while that read is pending) — same
// `view=plans` deep link every other Upgrade affordance uses (ChatPane,
// AvatarMenu, InlineModelSwitcher).
// every upgrade affordance: personal workspace → B's personal plan modal
// (`billing=plan`); team → `billing=checkout` vs `billing=plan` by whether
// the team ever completed a first checkout. Getting the personal branch wrong
// opened an error-state dialog: routing a personal workspace onto the team
// `billing=checkout` deep link opened the Upgrade-to-Team dialog with "Team
// plan unavailable" / a 3-seat minimum (recvpYEiH019cD, failed acceptance
// round). B now resolves `billing=plan` against the workspace's own state, so
// the team branch's guess is a hint rather than a requirement. The profile
// fallback keeps the CTA alive after a signed-out/no-context read (but not
// while that read is pending) — same `billing=plan` deep link every other
// Upgrade affordance uses (ChatPane, AvatarMenu, InlineModelSwitcher).
const {
context: workspaceContext,
loading: workspaceContextLoading,
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/AmrLowBalanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ interface Props {
onDecision: (decision: AmrLowBalanceDecision) => void;
}

// SOFT pre-run reminder for Open Design Cloud tasks: the wallet can still
// SOFT pre-run reminder for Open Design Cloud tasks: the balance can still
// fund a start but sits at or below the low-balance line, so the run may die
// mid-flight. Unlike the hard AmrBalanceDialog this never stands between the
// user and their task — "start anyway" resolves the SAME pending send the
// gate paused (a continuation, not a re-submit), "top up" opens the console
// wallet page and parks the send, and the "don't remind me" opt-out persists
// dashboard and parks the send, and the "don't remind me" opt-out persists
// for every future soft warning. Hard blocks ignore the opt-out by design.
export function AmrLowBalanceDialog({
balanceUsd,
Expand All @@ -56,7 +56,7 @@ export function AmrLowBalanceDialog({
commitOptOut();
onDecision(decision);
};
const openWalletAndPark = () => {
const openConsoleAndPark = () => {
const attribution = recordAmrEntry(analytics.track, entrySource, new Date(), {
metricsConsent,
});
Expand Down Expand Up @@ -112,7 +112,7 @@ export function AmrLowBalanceDialog({
<Button
variant="primary"
className={styles.cta}
onClick={openWalletAndPark}
onClick={openConsoleAndPark}
data-testid="amr-low-balance-dialog-recharge"
>
{t('chat.amrLowBalance.rechargeCta')}
Expand Down
91 changes: 47 additions & 44 deletions apps/web/src/components/EntryNavRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
notifyWorkspaceContextRefresh,
} from '../collab/useWorkspaceContext';
import { hasTeamPlan, resolvePlanLabelTier } from '../collab/team-plan';
import { amrPlansUrlForProfile } from '../runtime/amr-guidance';
import { AMR_CONSOLE_UPGRADE_INTENT, amrPlansUrlForProfile } from '../runtime/amr-guidance';
import type { EntryHomeView } from '../router';

const REPO_URL = 'https://github.com/nexu-io/open-design';
Expand Down Expand Up @@ -210,38 +210,35 @@ export function teamConsoleUrl(
// deep-link param depends on it.
options?: { hasActivePlan?: boolean },
): string {
// B's console routes: members live at /team, the (global) wallet backs the
// billing entry. The settings URL the context carries includes the
// ?workspaceId deep-link param; URL parsing preserves it, so the target page
// opens on the SAME workspace this client is pinned to (B asks the user to
// confirm if their account-level selection differs).
// B's console routes: members live at /team, everything account/billing
// shaped reports on the dashboard. The settings URL the context carries
// includes the ?workspaceId deep-link param; URL parsing preserves it, so
// the target page opens on the SAME workspace this client is pinned to (B
// asks the user to confirm if their account-level selection differs).
//
// `upgrade` lands on the team dashboard AND opens a subscription dialog, but
// WHICH dialog depends on whether the team has ever checked out before — B's
// `team-dashboard.tsx` gates them on mutually exclusive conditions:
// - `billing=checkout` opens the first-subscription dialog, gated by
// `canUpgradeTeam`, which requires the team's `subscriptionSummary
// .billingState` to be one of free/inactive/locked (never subscribed, or
// lapsed). For a team that already has an active plan this gate is
// false, so `billing=checkout` silently opens nothing — confirmed via a
// real recording (recvpSQKna0LwR) landing on the bare Overview page for
// an already-subscribed "Team Pro" workspace.
// - `billing=plan` opens the CHANGE-plan dialog, gated by
// `ownerBillingActionsAvailable`, which requires `billingState ===
// 'active'` — the mirror-image condition, for a team that already pays.
// `options.hasActivePlan` (callers pass `hasTeamPlan(context, billing)` from
// `collab/team-plan.ts`) picks the branch that actually matches the team's
// current subscription state instead of hardcoding the never-subscribed one.
// `plans` is the PERSONAL upgrade deep link: the wallet page with B's
// pricing modal auto-opened (`view=plans`) — verified live to auto-open for
// a personal-workspace session (recvpYEiH019cD). For a TEAM workspace B
// redirects this exact URL into `dashboard?billing=checkout` itself
// (vela wallet.tsx `teamWalletCheckoutRedirectPath`), so even a misrouted
// team session degrades to the first-checkout dialog, not a dead page.
// `billing` (the 「额度」 row) is a plain dashboard visit. It used to open a
// wallet page; that route still answers on B's side but is no longer part of
// the product's information architecture — balance, manual top-up and the
// auto-recharge policy were rehomed onto the dashboard (vela #1055).
//
// `upgrade` and `plans` both land on the dashboard AND ask it to open an
// upgrade dialog. B resolves `billing=plan` against the workspace's own
// subscription state, so one intent covers all three states: a personal
// owner gets the personal plan modal (the same one the console's 「升级订阅」
// hero button opens), a never-subscribed team gets first-checkout, and a
// subscribed team gets change-plan.
//
// `upgrade` additionally still passes `billing=checkout` for a team the
// caller knows has never subscribed (`options.hasActivePlan`, from
// `hasTeamPlan(context, billing)` in `collab/team-plan.ts`). That is now a
// hint rather than a requirement: B honors it when first-checkout is really
// available and otherwise falls back to the dialog that does match, so a
// stale `hasActivePlan` can no longer strand the user on a bare Overview
// page the way it did in recvpSQKna0LwR.
const path =
section === 'members' ? 'team'
: section === 'billing' ? 'wallet'
: section === 'plans' ? 'wallet'
: section === 'billing' ? 'dashboard'
: section === 'plans' ? 'dashboard'
: section === 'upgrade' ? 'dashboard'
: section === 'create-team' || section === 'invite' ? 'dashboard'
: section;
Expand All @@ -255,9 +252,12 @@ export function teamConsoleUrl(
}
url.pathname = `/${segments.join('/')}`;
if (section === 'upgrade') {
url.searchParams.set('billing', options?.hasActivePlan ? 'plan' : 'checkout');
url.searchParams.set(
'billing',
options?.hasActivePlan ? AMR_CONSOLE_UPGRADE_INTENT : 'checkout',
);
}
if (section === 'plans') url.searchParams.set('view', 'plans');
if (section === 'plans') url.searchParams.set('billing', AMR_CONSOLE_UPGRADE_INTENT);
// Vela owns the final invite action because only its dashboard has the
// authoritative subscription + seat state needed to choose between
// upgrading to Team, buying seats, and sending an invite. `invite=auto`
Expand Down Expand Up @@ -293,11 +293,12 @@ export function teamConsoleUrl(
* error state ("Team plan unavailable" / 3-seat minimum). recvpYEiH019cD,
* verified live with a real personal-workspace session.
*
* - personal (or type unknown) → `wallet?view=plans`, B's personal pricing
* modal — verified live to auto-open for the same session.
* - personal (or type unknown) → `dashboard?billing=plan`, B's personal plan
* modal — the same dialog the console's own 「升级订阅」 hero button opens.
* - team, never subscribed → `dashboard?billing=checkout` (first-checkout
* dialog); team, already subscribed → `dashboard?billing=plan`
* (change-plan dialog). See `teamConsoleUrl` for why B needs the split.
* (change-plan dialog). See `teamConsoleUrl` for why the team branch still
* sends the more specific hint even though B can now resolve either.
* - a resolved workspace without `canManageBilling` → null. Billing is
* owner-only, so admin/member surfaces hide the action rather than linking
* to an operation B will reject.
Expand Down Expand Up @@ -603,15 +604,17 @@ export function EntryNavRail({
const [inviteOpen, setInviteOpen] = useState(false);
const inviteTarget = resolveWorkspaceInviteTarget(context);
// One decision for both of this rail's upgrade entries (credits chip and
// the invite dialog's seat-gate): personal → the wallet pricing modal,
// team → checkout vs change-plan by subscription state. See
// the invite dialog's seat-gate): personal → the console's personal plan
// modal, team → checkout vs change-plan by subscription state. See
// `workspaceUpgradeUrl` for why the axis is the workspace TYPE.
const upgradeUrl = workspaceUpgradeUrl(context, billing);
const billingUpgradeUrl =
context?.billingRecovery?.recoveryUrl?.trim() || upgradeUrl;
// #62: the 积分 row links straight OUT to B's wallet page (usage detail lives
// there) — no intermediate credits popover in the client, matching #5517.
const billingWalletUrl = workspaceSettingsUrl
// #62: the 积分 row links straight OUT to B's console dashboard (usage detail
// lives there) — no intermediate credits popover in the client, matching
// #5517. It used to open a wallet page; balance, top-up and the auto-recharge
// policy were rehomed onto the dashboard (vela #1055).
const billingConsoleUrl = workspaceSettingsUrl
? teamConsoleUrl(workspaceSettingsUrl, 'billing')
: null;
// Product decision: plan selection / payment lives in Vela Web. The local
Expand Down Expand Up @@ -769,7 +772,7 @@ export function EntryNavRail({
) : null}
</div>
{/* #5517 billing card: plan (+badge) + 升级 CTA + USD balance.
The balance row links out to B's wallet page. It receives
The balance row links out to B's console. It receives
only an explicitly scoped money value; raw credits are
never formatted as dollars here. */}
{billing || balanceLabel ? (
Expand All @@ -793,16 +796,16 @@ export function EntryNavRail({
) : null}
</div>
{/* #62 (product ruling): clicking the balance jumps straight to
B's web wallet page for the usage detail — there is
B's console dashboard for the usage detail — there is
NO intermediate credits popover in the client. */}
<button
type="button"
className="entry-nav-rail__menu-credits-row"
data-testid="entry-nav-credits-row"
onClick={() => {
setAccountOpen(false);
if (billingWalletUrl) {
window.open(billingWalletUrl, '_blank', 'noopener,noreferrer');
if (billingConsoleUrl) {
window.open(billingConsoleUrl, '_blank', 'noopener,noreferrer');
}
}}
>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/RecentProjectsStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,9 @@ export function RecentProjectsStrip({
(workspaceContextHasTeamIdentity(workspaceContext) &&
workspaceContext?.permissions.canShareProjects === true);
const canAccessInviteFlow = canAccessWorkspaceInviteFlow(workspaceContext);
// The invite dialog's seat-gate upgrade CTA: personal workspace → B's wallet
// pricing modal, team → checkout vs change-plan by subscription state. One
// shared decision point — see `workspaceUpgradeUrl` in EntryNavRail.tsx
// The invite dialog's seat-gate upgrade CTA: personal workspace → B's
// personal plan modal, team → checkout vs change-plan by subscription state.
// One shared decision point — see `workspaceUpgradeUrl` in EntryNavRail.tsx
// (recvpYEiH019cD).
const inviteUpgradeUrl = workspaceUpgradeUrl(workspaceContext, workspaceBilling);
const inviteTarget = resolveWorkspaceInviteTarget(workspaceContext);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1642,7 +1642,7 @@ export function SettingsDialog({
// The 「升级」 buttons on the AMR model card route through
// `workspaceUpgradeUrl` — the one decision point every upgrade affordance
// shares (see its docblock in `EntryNavRail.tsx`): personal workspace →
// B's wallet pricing modal (`view=plans`, recvpYEiH019cD); team → the
// B's personal plan modal (`billing=plan`, recvpYEiH019cD); team → the
// checkout vs change-plan dashboard dialog by subscription state
// (recvpSQKna0LwR). The profile fallback keeps the buttons alive after a
// signed-out/no-context read; while that read is still loading, hide them so
Expand Down
Loading
Loading