Skip to content

Commit 4d5bbdc

Browse files
authored
feat(web): show AMR cloud card as a skeleton while detection is in flight (#4112)
The onboarding "choose a runtime" step hides the Open Design AMR cloud card until AMR's probe settles. Because AMR availability is gated behind a live `vela model list` network call (with retries), the card was simply absent for the several seconds detection takes on a real network, then popped in with no loading affordance — users saw only the Local CLI / BYOK alternatives and assumed AMR was unavailable. Render the AMR cloud card in a skeleton state while availability is still undecided (the cold-start detection stream is in flight, or the one-shot re-probe has not returned). The skeleton mirrors the real card's footprint exactly (same featured/amr grid, 246px min-height) so resolving causes no layout jump. The AMR brand (icon + name) is known up-front and rendered solid; only the probe-dependent parts — version meta, benefit list, and model picker — shimmer, using the repo's existing skeleton sweep. Honors prefers-reduced-motion. `agentsLoading` is threaded App -> EntryView -> EntryShell -> OnboardingView to drive the detecting window; once detection settles the card is either the real AMR card or omitted, exactly as before.
1 parent 36a9dcc commit 4d5bbdc

5 files changed

Lines changed: 242 additions & 4 deletions

File tree

apps/web/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,6 +2075,7 @@ function AppInner() {
20752075
promptTemplates={promptTemplates}
20762076
defaultDesignSystemId={config.designSystemId}
20772077
agents={agents}
2078+
agentsLoading={agentsLoading}
20782079
config={config}
20792080
providerModelsCache={providerModelsCache}
20802081
onProviderModelsCacheChange={setProviderModelsCache}

apps/web/src/components/EntryShell.tsx

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ interface Props {
296296
providerModelsCache?: ProviderModelsCache;
297297
onProviderModelsCacheChange?: Dispatch<SetStateAction<ProviderModelsCache>>;
298298
agents: AgentInfo[];
299+
// True while the cold-start agent detection stream is still in flight
300+
// (`fetchAgentsStream` has not reached its terminal `done`). Onboarding
301+
// uses this to show the AMR cloud card in a detecting/skeleton state
302+
// instead of hiding it during the seconds AMR's probe takes to settle.
303+
agentsLoading?: boolean;
299304
daemonLive: boolean;
300305
onModeChange: (mode: ExecMode) => void;
301306
onAgentChange: (id: string) => void;
@@ -415,6 +420,7 @@ export function EntryShell({
415420
providerModelsCache: sharedProviderModelsCache,
416421
onProviderModelsCacheChange,
417422
agents,
423+
agentsLoading = false,
418424
daemonLive,
419425
onModeChange,
420426
onAgentChange,
@@ -662,6 +668,7 @@ export function EntryShell({
662668
<OnboardingView
663669
config={config}
664670
agents={agents}
671+
agentsLoading={agentsLoading}
665672
providerModelsCache={activeProviderModelsCache}
666673
onProviderModelsCacheChange={activeSetProviderModelsCache}
667674
daemonLive={daemonLive}
@@ -903,6 +910,7 @@ function OnboardingView({
903910
providerModelsCache: sharedProviderModelsCache,
904911
onProviderModelsCacheChange,
905912
agents,
913+
agentsLoading = false,
906914
daemonLive,
907915
onModeChange,
908916
onAgentChange,
@@ -917,6 +925,7 @@ function OnboardingView({
917925
providerModelsCache?: ProviderModelsCache;
918926
onProviderModelsCacheChange?: Dispatch<SetStateAction<ProviderModelsCache>>;
919927
agents: AgentInfo[];
928+
agentsLoading?: boolean;
920929
daemonLive: boolean;
921930
onModeChange: (mode: ExecMode) => void;
922931
onAgentChange: (id: string) => void;
@@ -937,6 +946,11 @@ function OnboardingView({
937946
const [apiKeyVisible, setApiKeyVisible] = useState(false);
938947
const [cliScanStatus, setCliScanStatus] = useState<'idle' | 'scanning' | 'done'>('idle');
939948
const [amrStatus, setAmrStatus] = useState<VelaLoginStatus | null>(null);
949+
// True while the one-shot AMR re-probe (fired when the cold-start stream
950+
// settled without surfacing AMR) is in flight. Combined with
951+
// `agentsLoading`, this is the full window during which AMR availability
952+
// is still undecided — and the AMR cloud card renders its skeleton.
953+
const [amrRefreshPending, setAmrRefreshPending] = useState(false);
940954
const [amrLoginPending, setAmrLoginPending] = useState(false);
941955
const [amrLoginCancelPending, setAmrLoginCancelPending] = useState(false);
942956
const [newsletterSubmitting, setNewsletterSubmitting] = useState(false);
@@ -1030,7 +1044,13 @@ function OnboardingView({
10301044
(agent) => agent.available && agent.id !== 'amr' && visibleAgentIds.includes(agent.id),
10311045
);
10321046
const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null;
1033-
const showAmrCloudOption = amrAgent !== null || agents.length === 0;
1047+
// AMR availability is still undecided while the cold-start stream runs or
1048+
// the one-shot re-probe is in flight. During that window we show the AMR
1049+
// cloud card in a skeleton state rather than hiding it (the gap users hit
1050+
// today: card absent for several seconds, then it pops in). Once detection
1051+
// settles, the card is either real (amrAgent) or omitted.
1052+
const amrDetecting = !amrAgent && (agentsLoading || amrRefreshPending);
1053+
const showAmrCloudOption = amrAgent !== null;
10341054
const amrSignedIn = amrStatus?.loggedIn === true;
10351055
const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn;
10361056
const amrAgentChoice = config.agentModels?.amr ?? {};
@@ -1070,10 +1090,16 @@ function OnboardingView({
10701090
}, [amrAgent, onAgentChange, onModeChange, runtime]);
10711091

10721092
useEffect(() => {
1073-
if (amrAgent || amrAgentRefreshAttemptedRef.current) return;
1093+
// The cold-start stream finished without AMR. Re-probe once before we
1094+
// conclude AMR is unavailable, and keep the card in its skeleton state
1095+
// for the duration so it doesn't flash out-and-back-in.
1096+
if (amrAgent || amrAgentRefreshAttemptedRef.current || agentsLoading) return;
10741097
amrAgentRefreshAttemptedRef.current = true;
1075-
void Promise.resolve(onRefreshAgents()).catch(() => undefined);
1076-
}, [amrAgent, onRefreshAgents]);
1098+
setAmrRefreshPending(true);
1099+
void Promise.resolve(onRefreshAgents())
1100+
.catch(() => undefined)
1101+
.finally(() => setAmrRefreshPending(false));
1102+
}, [amrAgent, agentsLoading, onRefreshAgents]);
10771103

10781104
useEffect(() => {
10791105
if (!amrAgent) return;
@@ -1836,6 +1862,8 @@ function OnboardingView({
18361862
}}
18371863
/>
18381864
</div>
1865+
) : amrDetecting ? (
1866+
<OnboardingAmrCloudSkeleton />
18391867
) : null}
18401868
<div className="onboarding-view__alternatives">
18411869
{runtimeItems.map((item) => (
@@ -2777,6 +2805,53 @@ export function OnboardingDropdown(props: OnboardingDropdownProps) {
27772805
);
27782806
}
27792807

2808+
// Placeholder for the AMR cloud card shown while AMR availability is still
2809+
// being probed (the cold-start detection stream / one-shot re-probe). It
2810+
// mirrors the real card's footprint exactly — same featured/amr grid, same
2811+
// 246px min-height — so resolving to the real card causes no layout jump.
2812+
// The AMR brand (icon + name) is known up-front and rendered solid; only the
2813+
// version meta, benefit list, and model picker — the parts that depend on the
2814+
// probe result — shimmer. Non-interactive and announced via role="status".
2815+
function OnboardingAmrCloudSkeleton() {
2816+
const t = useT();
2817+
return (
2818+
<div className="onboarding-view__amr-cloud-card">
2819+
<div
2820+
className="onboarding-view__card onboarding-view__card--featured onboarding-view__card--amr onboarding-view__card--benefit-aside onboarding-view__card--skeleton"
2821+
role="status"
2822+
aria-busy="true"
2823+
aria-label={t('common.loading')}
2824+
>
2825+
<span className="onboarding-view__identity">
2826+
<span className="onboarding-view__icon onboarding-view__icon--asset">
2827+
<AgentIcon id="amr" size={52} className="onboarding-view__agent-logo" />
2828+
</span>
2829+
<span className="onboarding-view__card-copy">
2830+
<span className="onboarding-view__card-top">
2831+
<strong>{t('settings.amrCloud')}</strong>
2832+
</span>
2833+
<span className="onboarding-view__skeleton-line onboarding-view__skeleton-line--meta" />
2834+
</span>
2835+
</span>
2836+
<span className="onboarding-view__benefit-aside" aria-hidden="true">
2837+
<span className="onboarding-view__benefit-stack onboarding-view__benefit-stack--skeleton">
2838+
<span className="onboarding-view__skeleton-line onboarding-view__skeleton-line--benefit" />
2839+
<span className="onboarding-view__skeleton-line onboarding-view__skeleton-line--benefit" />
2840+
<span className="onboarding-view__skeleton-line onboarding-view__skeleton-line--benefit" />
2841+
<span className="onboarding-view__skeleton-line onboarding-view__skeleton-line--benefit" />
2842+
</span>
2843+
</span>
2844+
<span className="onboarding-view__card-model" aria-hidden="true">
2845+
<span className="onboarding-view__skeleton-model">
2846+
<span className="onboarding-view__skeleton-model-label" />
2847+
<span className="onboarding-view__skeleton-model-bar" />
2848+
</span>
2849+
</span>
2850+
</div>
2851+
</div>
2852+
);
2853+
}
2854+
27802855
function OnboardingChoiceCard({
27812856
icon,
27822857
agentIconId,

apps/web/src/components/EntryView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ interface Props {
5454
promptTemplates: PromptTemplateSummary[];
5555
defaultDesignSystemId: string | null;
5656
agents: AgentInfo[];
57+
// Forwarded to EntryShell → OnboardingView so the AMR cloud card can show a
58+
// detecting/skeleton state while the cold-start agent stream is in flight.
59+
agentsLoading?: boolean;
5760
// Execution / model-switching context forwarded to the EntryShell so the
5861
// sticky top-bar can expose the active CLI/BYOK + model and persist
5962
// changes through the same channels as the project view.
@@ -222,6 +225,7 @@ export function EntryView({
222225
promptTemplates,
223226
defaultDesignSystemId,
224227
agents,
228+
agentsLoading,
225229
config,
226230
providerModelsCache,
227231
onProviderModelsCacheChange,
@@ -343,6 +347,7 @@ export function EntryView({
343347
providerModelsCache={providerModelsCache}
344348
onProviderModelsCacheChange={onProviderModelsCacheChange}
345349
agents={agents}
350+
{...(agentsLoading !== undefined ? { agentsLoading } : {})}
346351
daemonLive={daemonLive}
347352
onModeChange={onModeChange}
348353
onAgentChange={onAgentChange}

apps/web/src/styles/home/entry-layout.css

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3441,3 +3441,123 @@
34413441
}
34423442

34433443
}
3444+
3445+
/* ============================================================
3446+
AMR cloud card — detecting / skeleton state
3447+
Shown while AMR availability is still being probed. Reuses the
3448+
featured/amr card classes (so min-height: 246px and the responsive
3449+
grid are inherited — no layout jump when the real card resolves).
3450+
Only the probe-dependent parts shimmer; the brand stays solid.
3451+
============================================================ */
3452+
.onboarding-view__card--skeleton {
3453+
cursor: default;
3454+
/* Neutral, un-selected resting surface — the card is not yet actionable,
3455+
so it must not read as the accent-selected state. */
3456+
border-color: var(--border);
3457+
background: var(--bg-panel);
3458+
box-shadow: var(--shadow-xs);
3459+
}
3460+
3461+
.onboarding-view__card--skeleton:hover {
3462+
/* Defeat the interactive card's hover lift/tint while detecting. */
3463+
border-color: var(--border);
3464+
background: var(--bg-panel);
3465+
transform: none;
3466+
}
3467+
3468+
/* Shared shimmer fill — matches the house skeleton sweep
3469+
(recent-projects / plugins-home): a soft highlight passing over a
3470+
muted base. Sheen kept intentionally low-contrast for a precise,
3471+
non-flashy read. */
3472+
.onboarding-view__skeleton-line,
3473+
.onboarding-view__skeleton-model-label,
3474+
.onboarding-view__skeleton-model-bar {
3475+
display: block;
3476+
border-radius: var(--radius-pill);
3477+
background:
3478+
linear-gradient(
3479+
110deg,
3480+
transparent 28%,
3481+
color-mix(in srgb, #fff 30%, transparent) 50%,
3482+
transparent 72%
3483+
) 0 0 / 200% 100%,
3484+
color-mix(in srgb, var(--bg-muted) 82%, var(--bg-panel));
3485+
animation: onboarding-skeleton-sheen 1.55s ease-in-out infinite;
3486+
}
3487+
3488+
[data-theme="dark"] .onboarding-view__skeleton-line,
3489+
[data-theme="dark"] .onboarding-view__skeleton-model-label,
3490+
[data-theme="dark"] .onboarding-view__skeleton-model-bar {
3491+
background:
3492+
linear-gradient(
3493+
110deg,
3494+
transparent 28%,
3495+
color-mix(in srgb, #fff 12%, transparent) 50%,
3496+
transparent 72%
3497+
) 0 0 / 200% 100%,
3498+
color-mix(in srgb, var(--bg-elevated) 70%, var(--bg-muted));
3499+
}
3500+
3501+
/* Version meta placeholder (sits where "AMR v0.1.0" renders). */
3502+
.onboarding-view__skeleton-line--meta {
3503+
width: 116px;
3504+
height: 11px;
3505+
margin-top: 2px;
3506+
}
3507+
3508+
/* Benefit list placeholders in the aside column. Staggered widths read as
3509+
real copy rather than a stack of identical bars. */
3510+
.onboarding-view__benefit-stack--skeleton {
3511+
gap: 11px;
3512+
}
3513+
3514+
.onboarding-view__skeleton-line--benefit {
3515+
height: 13px;
3516+
border-radius: var(--radius-sm);
3517+
}
3518+
.onboarding-view__benefit-stack--skeleton
3519+
.onboarding-view__skeleton-line--benefit:nth-child(1) { width: 90%; }
3520+
.onboarding-view__benefit-stack--skeleton
3521+
.onboarding-view__skeleton-line--benefit:nth-child(2) { width: 74%; }
3522+
.onboarding-view__benefit-stack--skeleton
3523+
.onboarding-view__skeleton-line--benefit:nth-child(3) { width: 83%; }
3524+
.onboarding-view__benefit-stack--skeleton
3525+
.onboarding-view__skeleton-line--benefit:nth-child(4) { width: 58%; }
3526+
3527+
/* Model picker placeholder (sits where the AMR model select renders). */
3528+
.onboarding-view__skeleton-model {
3529+
display: grid;
3530+
gap: 8px;
3531+
width: min(260px, 100%);
3532+
}
3533+
.onboarding-view__card--amr .onboarding-view__skeleton-model {
3534+
width: 100%;
3535+
}
3536+
.onboarding-view__skeleton-model-label {
3537+
width: 96px;
3538+
height: 11px;
3539+
}
3540+
.onboarding-view__skeleton-model-bar {
3541+
width: 100%;
3542+
height: 40px;
3543+
border-radius: var(--radius);
3544+
/* Slightly stagger the sweep from the label so the two don't pulse in
3545+
lockstep — reads as one surface loading, not two blinking blocks. */
3546+
animation-delay: 160ms;
3547+
}
3548+
3549+
@keyframes onboarding-skeleton-sheen {
3550+
to {
3551+
background-position: -200% 0, 0 0;
3552+
}
3553+
}
3554+
3555+
@media (prefers-reduced-motion: reduce) {
3556+
.onboarding-view__skeleton-line,
3557+
.onboarding-view__skeleton-model-label,
3558+
.onboarding-view__skeleton-model-bar {
3559+
animation: none;
3560+
background:
3561+
color-mix(in srgb, var(--bg-muted) 82%, var(--bg-panel));
3562+
}
3563+
}

apps/web/tests/components/EntryShell.onboarding.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,43 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
820820
});
821821
});
822822

823+
it('shows the AMR cloud card as a skeleton while agent detection is still in flight', async () => {
824+
// Before this fix, the AMR cloud card was simply absent for the several
825+
// seconds AMR's probe takes to settle (showAmrCloudOption was false once
826+
// any non-AMR agent had arrived), then popped in with no loading state.
827+
globalThis.fetch = vi.fn(async () =>
828+
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
829+
) as typeof fetch;
830+
renderOnboarding({
831+
agents: [cliAgent()], // AMR has not surfaced from the stream yet
832+
agentsLoading: true, // cold-start detection stream still running
833+
onRefreshAgents: vi.fn(() => [cliAgent()]),
834+
});
835+
836+
const skeleton = document.querySelector('.onboarding-view__card--skeleton');
837+
expect(skeleton).toBeTruthy();
838+
// The brand identity is known up-front and rendered solid; only the
839+
// probe-dependent details shimmer.
840+
expect(skeleton?.textContent).toContain('Open Design AMR');
841+
expect(skeleton?.getAttribute('aria-busy')).toBe('true');
842+
expect(skeleton?.querySelectorAll('.onboarding-view__skeleton-line--benefit').length).toBe(4);
843+
expect(skeleton?.querySelector('.onboarding-view__skeleton-model-bar')).toBeTruthy();
844+
// The real, selectable AMR card is not present while detecting.
845+
expect(screen.queryByRole('button', { name: /Open Design AMR/i })).toBeNull();
846+
// Alternatives remain available throughout detection.
847+
expect(screen.getByRole('button', { name: /Local coding agent/i })).toBeTruthy();
848+
});
849+
850+
it('renders the real AMR cloud card and no skeleton once AMR is available', async () => {
851+
globalThis.fetch = vi.fn(async () =>
852+
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
853+
) as typeof fetch;
854+
renderOnboarding({ agentsLoading: false });
855+
856+
expect(screen.getByRole('button', { name: /Open Design AMR/i })).toBeTruthy();
857+
expect(document.querySelector('.onboarding-view__card--skeleton')).toBeNull();
858+
});
859+
823860
it('lets Skip exit onboarding without starting AMR login', async () => {
824861
const fetchMock = vi.fn(async (_input: RequestInfo | URL) =>
825862
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),

0 commit comments

Comments
 (0)